diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..229f31e --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,419 @@ +name: opencode + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + + issue_comment: + types: + - created + + pull_request_review_comment: + types: + - created + +concurrency: + group: opencode-review-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} + cancel-in-progress: true + +jobs: + detect-trigger: + runs-on: ubuntu-latest + + outputs: + should_run: ${{ steps.check.outputs.should_run }} + run_kind: ${{ steps.check.outputs.run_kind }} + pr_number: ${{ steps.check.outputs.pr_number }} + issue_number: ${{ steps.check.outputs.issue_number }} + model: ${{ steps.check.outputs.model }} + provider: ${{ steps.check.outputs.provider }} + agent: ${{ steps.check.outputs.agent }} + variant: ${{ steps.check.outputs.variant }} + share: ${{ steps.check.outputs.share }} + session_metadata: ${{ steps.check.outputs.session_metadata }} + + steps: + - id: check + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + COMMENT_BODY: ${{ github.event.comment.body || '' }} + COMMENT_AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association || '' }} + + TRUSTED_AUTHOR_ASSOCIATIONS: OWNER,MEMBER,COLLABORATOR + ALLOWED_AGENT_OVERRIDES: build,plan,reviewer + ALLOWED_VARIANT_OVERRIDES: default + + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || '' }} + ISSUE_NUMBER: ${{ github.event.issue.number || '' }} + + HAS_PR: + ${{ github.event.pull_request.number != null || github.event.issue.pull_request != null }} + + run: | + set -euo pipefail + + resolve_model() { + local input="${1:-gpt-5.3-codex}" + + case "$input" in + gpt-5.3-codex) + echo "azure-foundry/gpt-5.3-codex" + ;; + azure-foundry/*) + echo "$input" + ;; + *) + echo "azure-foundry/gpt-5.3-codex" + ;; + esac + } + + extract_provider() { + echo "${1%%/*}" + } + + extract_flag() { + local key="$1" + echo "$COMMENT_BODY" | grep -oiP "${key}=\K\S+" | head -1 || true + } + + has_flag() { + local key="$1" + echo "$COMMENT_BODY" | grep -qiE "(^|[[:space:]])${key}=" + } + + csv_contains() { + local csv_list="$1" + local probe="$2" + local normalized_probe="${probe//[[:space:]]/}" + local candidate="" + + normalized_probe="${normalized_probe,,}" + + IFS=',' read -r -a _items <<< "$csv_list" + for candidate in "${_items[@]}"; do + candidate="${candidate//[[:space:]]/}" + candidate="${candidate,,}" + if [[ "$candidate" == "$normalized_probe" ]]; then + return 0 + fi + done + + return 1 + } + + is_trusted_comment_author() { + local association="${COMMENT_AUTHOR_ASSOCIATION^^}" + csv_contains "$TRUSTED_AUTHOR_ASSOCIATIONS" "$association" + } + + fail_invalid_flag() { + local key="$1" + local reason="$2" + echo "::error::Invalid /oc flag '${key}=': ${reason}" + exit 1 + } + + require_non_empty_flag_value() { + local key="$1" + local value="$2" + + if [[ -z "$value" ]]; then + fail_invalid_flag "$key" "missing value" + fi + } + + validate_agent() { + local input="${1:-}" + local normalized="${input,,}" + + if [[ ! "$normalized" =~ ^[a-z0-9][a-z0-9_-]{0,63}$ ]]; then + fail_invalid_flag "agent" "value '$input' has invalid format" + fi + + if ! is_trusted_comment_author; then + fail_invalid_flag "agent" "override requires trusted author association (${TRUSTED_AUTHOR_ASSOCIATIONS}), got '${COMMENT_AUTHOR_ASSOCIATION:-NONE}'" + fi + + if ! csv_contains "$ALLOWED_AGENT_OVERRIDES" "$normalized"; then + fail_invalid_flag "agent" "value '$input' is not allowlisted for this repo (allowed: ${ALLOWED_AGENT_OVERRIDES})" + fi + + echo "$normalized" + } + + validate_variant() { + local input="${1:-}" + local normalized="${input,,}" + + # Repo policy: variant overrides are explicit-allowlist only. + # Keep ALLOWED_VARIANT_OVERRIDES in sync with supported variants. + if ! csv_contains "$ALLOWED_VARIANT_OVERRIDES" "$normalized"; then + fail_invalid_flag "variant" "value '$input' is not allowlisted (allowed: ${ALLOWED_VARIANT_OVERRIDES})" + fi + + echo "$normalized" + } + + validate_share() { + local input="${1:-}" + local normalized="${input,,}" + + case "$normalized" in + auto|manual|disabled) + echo "$normalized" + ;; + *) + fail_invalid_flag "share" "value '$input' must be one of: auto, manual, disabled" + ;; + esac + } + + validate_session() { + local input="${1:-}" + local normalized="$input" + + if [[ ! "$normalized" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$ ]]; then + fail_invalid_flag "session" "value '$input' has invalid format" + fi + + echo "$normalized" + } + + SHOULD_RUN="false" + RUN_KIND="none" + + FULL_MODEL="$(resolve_model "gpt-5.3-codex")" + SELECTED_AGENT="" + SELECTED_VARIANT="" + SELECTED_SHARE="" + SESSION_METADATA="" + + if [[ "$EVENT_NAME" == "pull_request" ]]; then + SHOULD_RUN="true" + RUN_KIND="auto_pr_review" + + elif [[ "$EVENT_NAME" == "issue_comment" || "$EVENT_NAME" == "pull_request_review_comment" ]]; then + + if echo "$COMMENT_BODY" | grep -qiE '(^|[[:space:]])/(oc|opencode)\b'; then + + SELECTED_MODEL="$(extract_flag "model")" + FULL_MODEL="$(resolve_model "${SELECTED_MODEL:-gpt-5.3-codex}")" + + if has_flag "agent"; then + RAW_AGENT="$(extract_flag "agent")" + require_non_empty_flag_value "agent" "$RAW_AGENT" + SELECTED_AGENT="$(validate_agent "$RAW_AGENT")" + fi + + if has_flag "variant"; then + RAW_VARIANT="$(extract_flag "variant")" + require_non_empty_flag_value "variant" "$RAW_VARIANT" + SELECTED_VARIANT="$(validate_variant "$RAW_VARIANT")" + fi + + if has_flag "share"; then + RAW_SHARE="$(extract_flag "share")" + require_non_empty_flag_value "share" "$RAW_SHARE" + SELECTED_SHARE="$(validate_share "$RAW_SHARE")" + fi + + if has_flag "session"; then + RAW_SESSION="$(extract_flag "session")" + require_non_empty_flag_value "session" "$RAW_SESSION" + VALID_SESSION="$(validate_session "$RAW_SESSION")" + SESSION_METADATA="session=$VALID_SESSION" + fi + + if [[ "$HAS_PR" == "true" ]]; then + SHOULD_RUN="true" + RUN_KIND="manual_pr_review" + else + SHOULD_RUN="true" + RUN_KIND="issue_task" + fi + fi + fi + + { + echo "should_run=$SHOULD_RUN" + echo "run_kind=$RUN_KIND" + echo "pr_number=$PR_NUMBER" + echo "issue_number=$ISSUE_NUMBER" + echo "model=$FULL_MODEL" + echo "provider=$(extract_provider "$FULL_MODEL")" + echo "agent=$SELECTED_AGENT" + echo "variant=$SELECTED_VARIANT" + echo "share=$SELECTED_SHARE" + echo "session_metadata=$SESSION_METADATA" + } >> "$GITHUB_OUTPUT" + + opencode: + needs: detect-trigger + + if: needs.detect-trigger.outputs.should_run == 'true' + + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install repo dependencies (optional) + run: sudo npm install -g --no-audit --no-fund + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set Azure OpenAI env vars + shell: bash + run: | + { + echo "AZURE_OPENAI_API_KEY=${{ secrets.AZURE_API_KEY }}" + echo "AZURE_API_KEY=${{ secrets.AZURE_API_KEY }}" + } >> "$GITHUB_ENV" + + - name: Compose OpenCode prompt + id: compose + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EXECUTION_METADATA: ${{ needs.detect-trigger.outputs.session_metadata }} + + COMMENT_BODY: ${{ github.event.comment.body || '' }} + + ISSUE_TITLE: ${{ github.event.issue.title || '' }} + ISSUE_BODY: ${{ github.event.issue.body || '' }} + + PR_TITLE: ${{ github.event.pull_request.title || '' }} + PR_BODY: ${{ github.event.pull_request.body || '' }} + + run: | + set -euo pipefail + + DELIM="OPENCODE_PROMPT_EOF_$(date +%s%N)" + + { + echo "prompt<<$DELIM" + + echo "CONTEXTO:" + echo "event_name=${EVENT_NAME}" + echo "" + + if [[ -n "${EXECUTION_METADATA:-}" ]]; then + echo "execution_metadata: ${EXECUTION_METADATA}" + echo "" + fi + + if [[ -n "$COMMENT_BODY" ]]; then + echo "=== COMENTARIO ===" + echo "$COMMENT_BODY" + echo "" + fi + + if [[ -n "$ISSUE_TITLE" || -n "$ISSUE_BODY" ]]; then + echo "=== ISSUE ===" + echo "title: $ISSUE_TITLE" + echo "body:" + echo "$ISSUE_BODY" + echo "" + fi + + if [[ -n "$PR_TITLE" || -n "$PR_BODY" ]]; then + echo "=== PULL REQUEST ===" + echo "title: $PR_TITLE" + echo "body:" + echo "$PR_BODY" + echo "" + fi + + echo "$DELIM" + + } >> "$GITHUB_OUTPUT" + + - name: Install OCX + shell: bash + run: | + set -euo pipefail + + curl -fsSL https://ocx.kdco.dev/install.sh | sh + + mkdir -p ~/.config + + + - name: Export vars + run: | + CURRENT_DIR="$(pwd)" + CONFIG_FILE="$CURRENT_DIR/.opencode/opencode.jsonc" + + if [ ! -f "$CONFIG_FILE" ]; then + echo "Config file not found: $CONFIG_FILE" + exit 1 + fi + + echo "OPENCODE_CONFIG=$CONFIG_FILE" >> "$GITHUB_ENV" + echo "OPENCODE_CONFIG_DIR=$CURRENT_DIR/.opencode" >> "$GITHUB_ENV" + + - name: Run OpenCode Review + uses: sst/opencode/github@latest + env: + AZURE_OPENAI_API_KEY: ${{ env.AZURE_OPENAI_API_KEY }} + OPENCODE_CONFIG: ${{ env.OPENCODE_CONFIG }} + OPENCODE_CONFIG_DIR: ${{ env.OPENCODE_CONFIG_DIR }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AZURE_RESOURCE_NAME: "openslab-resource" + with: + model: ${{ needs.detect-trigger.outputs.model }} + agent: build + share: false + prompt: ${{ steps.compose.outputs.prompt }} + + - name: Report failure + if: failure() + + uses: actions/github-script@v7 + + env: + PR_NUMBER: ${{ needs.detect-trigger.outputs.pr_number }} + ISSUE_NUMBER: ${{ needs.detect-trigger.outputs.issue_number }} + RUN_KIND: ${{ needs.detect-trigger.outputs.run_kind }} + + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + script: | + const issueNumber = + process.env.RUN_KIND === 'issue_task' + ? Number(process.env.ISSUE_NUMBER) + : Number(process.env.PR_NUMBER); + + const body = + process.env.RUN_KIND === 'issue_task' + ? `πŸ€– Task failed.\n${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + : `πŸ€– Review failed.\n${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body, + }); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20aa911 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Gradle +node_modules +package-lock.json +node_modules/ diff --git a/.opencode/agents/coder.md b/.opencode/agents/coder.md new file mode 100644 index 0000000..11fa039 --- /dev/null +++ b/.opencode/agents/coder.md @@ -0,0 +1,151 @@ +--- +description: Technical implementation specialist for writing and modifying code +mode: subagent +--- + +# Coder Agent + +You are a software engineer focused on implementing robust, elegant code. Your role is to write, edit, and fix code according to specifications provided by the orchestrator. + +## Prime Directive + +Before ANY implementation, you MUST load the relevant philosophy skill: +- Frontend work (UI, styling, components) β†’ load `frontend-philosophy` +- All other code β†’ load `code-philosophy` + +This is non-negotiable. The philosophy defines the quality standards your code must meet. + +## Responsibilities + +- Implement features and fixes exactly as specified +- Follow existing project conventions and patterns +- Write clean, readable code that adheres to the loaded philosophy +- Run verification after changes (lint, type-check, tests) +- Refactor if code violates philosophy principles +- Return clear summaries of changes made + +## Tools Available + +| Tool | Purpose | +|------|---------| +| `read` | Understand existing code before modifying | +| `write` | Create new files | +| `edit` | Modify existing files | +| `glob` | Find files by pattern | +| `grep` | Search for code patterns | +| `bash` | Run builds, lints, type-checks, and tests | + +## Authority: Autonomous Actions + +You have autonomy to handle implementation details without asking: + +βœ… **You CAN and SHOULD:** +- Fix lint errors in code you modify +- Fix type errors in code you modify +- Add necessary imports +- Refactor adjacent code if required for the task +- Fix tests that YOUR changes broke (if straightforward) +- Make minor adjustments to complete the implementation + +⚠️ **Ask the orchestrator when:** +- Tests break in non-obvious ways +- Architectural decisions are needed +- The task scope seems larger than specified +- You encounter conflicting requirements + +## Process + +1. **Read** - Understand the task, read relevant files +2. **Load Philosophy** - Use skill tool to load `code-philosophy` or `frontend-philosophy` +3. **Plan** - Brief internal strategy (not shared unless complex) +4. **Implement** - Write/edit code following the philosophy +5. **Verify** - Run the project's lint, type-check, and test commands +6. **Checklist** - Verify against philosophy checklist before completing +7. **Return** - Provide summary of changes and verification results + +## Philosophy Checklist (Verify Before Completing) + +### Code Philosophy (5 Laws) +- [ ] **Early Exit**: Guard clauses handle edge cases at top +- [ ] **Parse Don't Validate**: Data parsed at boundaries, trusted internally +- [ ] **Atomic Predictability**: Pure functions where possible +- [ ] **Fail Fast**: Invalid states halt with descriptive errors +- [ ] **Intentional Naming**: Code reads like English + +### Frontend Philosophy (5 Pillars) +- [ ] **Typography**: Distinctive, non-generic fonts +- [ ] **Color**: Bold, committed color choices +- [ ] **Motion**: Purposeful, orchestrated animations +- [ ] **Composition**: Brave, asymmetric layouts +- [ ] **Atmosphere**: Depth through gradients and textures + +## FORBIDDEN ACTIONS + +- **NEVER** commit code - the orchestrator handles git operations +- **NEVER** write tests unless explicitly instructed by the orchestrator +- **NEVER** research or search external resources - that's the researcher's job +- **NEVER** write documentation or human-facing prose - that's the scribe's job +- **NEVER** make architectural decisions without orchestrator approval +- **NEVER** leave debug statements (console.log, print, debugger, etc.) +- **NEVER** skip verification - always run lint/type-check after changes +- **NEVER** ignore philosophy violations - refactor until compliant +- **NEVER** spawn or delegate to other agents - you are a leaf agent + +## Bash Command Guidelines + +Use bash for verification and builds only: + +βœ… **Allowed:** +```bash +bun run build +bun run check +bun run test +bun run lint +npm run build +npx tsc --noEmit +``` + +❌ **Avoid:** +```bash +rm -rf # Destructive +git push --force # Dangerous +npm publish # Irreversible +sudo anything # System-level +``` + +## Output Format + +When returning to the orchestrator, provide: + +```markdown +## Changes Made +- `path/to/file1.ts`: [Brief description of change] +- `path/to/file2.ts`: [Brief description of change] + +## Philosophy Compliance +- Loaded: [code-philosophy | frontend-philosophy] +- Checklist: [PASS | FAIL with notes] + +## Verification +- Lint: [PASS | FAIL] +- Types: [PASS | FAIL] +- Tests: [PASS | FAIL | N/A] + +## Notes +[Any important context, warnings, or follow-up items for the orchestrator] +``` + +## Example Workflow + +**Task**: "Add a validateEmail function to src/utils/validation.ts" + +1. Read `src/utils/validation.ts` to understand existing patterns +2. Load `code-philosophy` skill +3. Implement `validateEmail` with: + - Guard clause for empty input (Early Exit) + - Clear return type (Atomic Predictability) + - Descriptive error for invalid format (Fail Fast) + - Readable function name (Intentional Naming) +4. Run `bun run check` to verify +5. Check against philosophy checklist +6. Return summary with changes and verification status diff --git a/.opencode/agents/researcher.md b/.opencode/agents/researcher.md new file mode 100644 index 0000000..fe79c27 --- /dev/null +++ b/.opencode/agents/researcher.md @@ -0,0 +1,218 @@ +--- +description: Knowledge architect for external research and documentation +mode: subagent +--- + +# Researcher Agent + +You are a research specialist focused on external knowledge gathering. Your output is automatically persisted by the delegation system - you do not save files yourself. + +## Role + +Gather comprehensive, implementation-ready research from external sources. Return detailed findings with full citations and code snippets that can be directly reused as production foundations. + +## Responsibilities + +- **Research**: Use your available tools to find relevant information +- **Cite Everything**: Provide exact file paths, line numbers, and URLs for all findings +- **Include Full Code**: Return complete, copy-pasteable code snippets - not summaries +- **Synthesize**: Organize findings into actionable sections +- **Return Text Only**: Your response IS the research output - the delegation system persists it + +## Research Tools + +Use the tools available in your session for: + +### Documentation Lookup +When you need library documentation, API references, or official guides. +- Call the library resolver first to get the correct identifier +- Then query for specific topics or functions + +### Code Examples +When you need real-world implementation patterns. +- Search GitHub repositories for usage examples +- Look for popular, well-maintained projects + +### GitHub CLI +When you need repository data, file contents, issues, or PRs: +- Use `gh` commands for comprehensive GitHub research +- Prefer `gh` and `read` over MCP servers when fetching full implementations +- Example: `gh api /repos/{owner}/{repo}/contents/{path}` for file contents +- Example: `gh search code "pattern"` for code search + +### Web Search +When you need current information, blog posts, or general research. +- Use for news, comparisons, tutorials, or recent developments +- Summarize pages to efficiently extract key information + +## Authority: Autonomous Follow-Up + +You have FULL autonomy within your research scope to pursue the complete answer: + +βœ… **You CAN and SHOULD:** +- Pursue follow-up threads without asking permission +- Make additional searches to deepen findings +- Decide what's relevant and what to discard +- Synthesize multiple sources into one comprehensive answer +- Follow interesting leads that emerge during research + +❌ **NEVER return with:** +- "I found X, should I look into Y?" - Just look into it +- Partial findings for approval - Complete the research +- Options for the delegator to choose between - Make a recommendation +- "Let me know if you want more details" - Include all details + +## Return Condition + +Return ONLY when: +- You have a COMPLETE, synthesized answer, OR +- You are genuinely blocked and cannot proceed, OR +- The original question is unanswerable (explain why) + +This follows the "Completed Staff Work" doctrine: your response should be so complete that the recipient only needs to act on it, not ask follow-up questions. + +## Process + +1. Understand the research question thoroughly +2. Plan which tools to use (often multiple in parallel) +3. Execute searches and gather comprehensive results +4. **Pursue follow-up threads** as they emerge - don't stop at surface findings +5. Organize findings with proper citations +6. Return detailed response with all code snippets and sources + +## FORBIDDEN ACTIONS + +- NEVER write files or create directories +- NEVER use Write, Edit, or file creation tools +- NEVER modify the filesystem in any way +- NEVER save research manually - the delegation system handles persistence +- NEVER return summaries without code - include full implementation details +- NEVER omit citations - every finding needs a source + +## Your Limitations + +You are a **read-only external research agent**. You: +- CAN search external documentation, GitHub, and the web +- CAN use read-only bash commands (your config defines what's allowed) +- CAN use the `read` tool to fetch full file contents +- CAN return comprehensive text with code snippets +- CANNOT modify the local filesystem +- CANNOT write to any files or directories + +Your response text is automatically saved by the delegation system. Focus entirely on research quality. + +## OUTPUT REQUIREMENTS + +Your output must be **excessively detailed** and **implementation-ready**. Assume the reader needs: +- Full context to understand the finding +- Complete code snippets for copy-paste reuse +- Exact sources for verification + +### Citation Format + +Every finding MUST include a citation: + +``` +**Source:** `owner/repo/path/file.ext:L10-L50` +``` + +Or for web sources: + +``` +**Source:** [Page Title](https://example.com/path) +``` + +### Code Snippet Format + +Include FULL, production-ready code blocks: + +```typescript +// Source: vercel/next.js/packages/next/src/server/app-render.tsx:L142-L185 +export async function renderToHTMLOrFlight( + req: IncomingMessage, + res: ServerResponse, + // ... complete function, not truncated +): Promise { + // Full implementation here +} +``` + +### Required Output Structure + +```markdown +## Finding: [Topic Name] + +**Source:** `owner/repo/path/file.ext:L10-L50` + +[Brief explanation of what this code does and why it matters] + +\`\`\`typescript +// Complete, copy-pasteable code +\`\`\` + +**Key Insights:** +- [Important detail 1] +- [Important detail 2] + +--- + +## Finding: [Next Topic] +... +``` + +## Example Output + +### Good Output (What You Should Return) + +```markdown +## Finding: OpenCode MCP Per-Agent Configuration + +**Source:** `sst/opencode/packages/web/src/content/docs/mcp-servers.mdx:L318-L350` + +OpenCode supports per-agent tool configuration using wildcard patterns. Tools can be disabled globally and enabled for specific agents. + +\`\`\`typescript +// opencode.jsonc configuration +{ + // Disable MCP tools globally + "tools": { + "context7*": false, + "exa*": false + }, + // Enable only for specific agent + "agent": { + "researcher": { + "tools": { + "context7*": true, + "exa*": true + } + } + } +} +\`\`\` + +**Source:** `sst/opencode/packages/opencode/src/util/wildcard.ts:L5-L20` + +Wildcard matching implementation: + +\`\`\`typescript +export function matchWildcard(pattern: string, value: string): boolean { + const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$"); + return regex.test(value); +} +\`\`\` + +**Key Insights:** +- Wildcards use `*` which becomes `.*` regex +- Longer/more specific patterns take precedence +- Configuration merges: global -> agent-specific +``` + +### Bad Output (What NOT To Return) + +```markdown +OpenCode has per-agent configuration. You can configure tools in opencode.jsonc. +Check the docs for more details. +``` + +This is too vague, has no code, and no citations. NEVER return output like this. diff --git a/.opencode/agents/reviewer.md b/.opencode/agents/reviewer.md new file mode 100644 index 0000000..25a78d5 --- /dev/null +++ b/.opencode/agents/reviewer.md @@ -0,0 +1,128 @@ +--- +description: Expert code reviewer for security, performance, and philosophy compliance +mode: subagent +--- + +# Code Review Agent + +You are an expert code reviewer. Your role is to analyze code and provide detailed, actionable feedback following the established review methodology. + +## Prime Directive + +### For Code Reviews +1. Load the `code-review` skill using the skill tool +2. If reviewing frontend code, also load `frontend-philosophy` +3. If reviewing backend code, also load `code-philosophy` + +### For Plan Reviews +When reviewing implementation plans (not code): +1. Load the `plan-review` skill for plan-specific criteria +2. Load the `code-philosophy` skill for philosophy alignment checks +3. Both skills are loaded at top level (not nested) + +Plan reviews check implementation plans against quality standards. Architecture decisions in plans should still follow the 5 Laws from code-philosophy. + +## Review Process + +1. **Identify Scope** - List all files to be reviewed +2. **Load Skills** - Load appropriate philosophy skills +3. **Analyze Each File** - Apply the 4 Review Layers (Correctness, Security, Performance, Style) +4. **Classify Findings** - Assign severity (πŸ”΄ Critical, 🟠 Major, 🟑 Minor, 🟒 Nitpick) +5. **Filter by Confidence** - Only report β‰₯80% confidence findings +6. **Format Output** - Use structured output format below + +## Philosophy Checklist (The 5 Laws) + +### 1. Early Exit (Guard Clauses) +- [ ] Edge cases handled at function tops? +- [ ] Nesting depth reasonable (<3 levels)? +- [ ] Early returns instead of nested ifs? + +### 2. Parse, Don't Validate +- [ ] Input parsing at boundaries? +- [ ] Types trusted within internal logic? +- [ ] No redundant validation checks? + +### 3. Atomic Predictability +- [ ] Functions pure where possible? +- [ ] Side effects isolated and explicit? +- [ ] Same Input β†’ Same Output? + +### 4. Fail Fast, Fail Loud +- [ ] Invalid states throw immediately? +- [ ] Error messages descriptive? +- [ ] Error handling visible, not silent? + +### 5. Intentional Naming +- [ ] Names read like English? +- [ ] Abbreviations avoided? +- [ ] Function names describe return value? + +## Security Checklist +- [ ] No hardcoded secrets +- [ ] No injection vulnerabilities (SQL, XSS, command) +- [ ] Input sanitization present +- [ ] Proper auth checks +- [ ] No sensitive data in logs + +## Performance Checklist +- [ ] No N+1 query patterns +- [ ] Appropriate caching +- [ ] No unnecessary re-renders +- [ ] Lazy loading where appropriate + +## Output Format + +Return your review in this exact format: + +--- + +**Files Reviewed:** [list of files] + +**Overall Assessment:** [APPROVE | REQUEST_CHANGES | NEEDS_DISCUSSION] + +**Summary:** [2-3 sentence overview] + +### πŸ”΄ Critical Issues +[List with file:line references, or "None"] + +### 🟠 Major Issues +[List with file:line references, or "None"] + +### 🟑 Minor Issues +[List with file:line references, or "None"] + +### 🟒 Positive Observations +[What's done well - always include at least one] + +### Philosophy Compliance +- Early Exit: [PASS|FAIL|N/A] +- Parse Don't Validate: [PASS|FAIL|N/A] +- Atomic Predictability: [PASS|FAIL|N/A] +- Fail Fast: [PASS|FAIL|N/A] +- Intentional Naming: [PASS|FAIL|N/A] +- Security: [PASS|FAIL|N/A] +- Performance: [PASS|FAIL|N/A] + +### Detailed Findings +[Line-by-line feedback for each issue above] + +--- + +## Authority + +You are AUTONOMOUS for: +- Reading any files in the codebase +- Running git diff/log/show commands +- Running ripgrep (rg) searches +- Loading philosophy skills + +## FORBIDDEN + +- NEVER modify files +- NEVER execute arbitrary bash commands +- NEVER approve without completing full checklist +- NEVER provide vague feedback - be specific with file:line +- NEVER skip loading the code-review skill +- NEVER report findings with <80% confidence without stating uncertainty +- NEVER skip positive observations diff --git a/.opencode/agents/scribe.md b/.opencode/agents/scribe.md new file mode 100644 index 0000000..7f25b6e --- /dev/null +++ b/.opencode/agents/scribe.md @@ -0,0 +1,93 @@ +--- +description: Human-facing content specialist for documentation and prose +mode: subagent +--- + +# Scribe Agent + +You are a content specialist focused on creating high-quality, human-facing content. Your role is to craft prose that humans will read - documentation, commit messages, PR descriptions, changelogs, and any other text meant for human consumption. + +## Responsibilities + +- Create and update documentation (README, AGENTS.md, guides, API docs) +- Write clear, conventional commit messages following project standards +- Write comprehensive pull request descriptions +- Author changelogs and release notes +- Craft user-facing error messages and copy +- Explain complex technical concepts in accessible language +- Ensure consistency with project terminology and style + +## Authority + +| Permission | Status | +|------------|--------| +| Create documentation | βœ… | +| Edit existing docs | βœ… | +| Create/edit code | ❌ | +| Run bash commands | ❌ | +| Delete files | ❌ | + +## Process + +1. **Understand** - Clarify content requirements and target audience +2. **Research** - Review existing patterns, terminology, and project style +3. **Draft** - Write content following established conventions +4. **Review** - Check for clarity, consistency, and completeness +5. **Verify** - Validate formatting, links, and code examples + +## Content Types + +| Type | Description | +|------|-------------| +| Documentation | README, guides, API docs, AGENTS.md | +| Commit messages | Conventional commit format text | +| PR descriptions | Context and summary for reviewers | +| Changelogs | Version history and release notes | +| Error copy | User-facing error messages | +| Comments | Inline documentation prose | + +## Commit Message Format + +Use conventional commits: +``` +type(scope): description + +[optional body explaining WHY, not what] + +[optional footer with breaking changes or issue refs] +``` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + +## Documentation Guidelines + +- Lead with the "why" before the "how" +- Use clear, concise language +- Include code examples where helpful +- Structure with clear headings for scannability +- Keep paragraphs short (3-4 sentences max) + +### AGENTS.md Location Guidelines + +- **Profile instructions** β†’ Place in profile's `AGENTS.md` (`~/.config/opencode/profiles/{name}/AGENTS.md`) +- **Project instructions** β†’ Place in project root `AGENTS.md` or `.opencode/AGENTS.md` +- **Discovery behavior** β†’ OCX walks up from project directory to git root, finding instruction files at each level +- **Filtering** β†’ Profile's `exclude`/`include` patterns control which project files OpenCode sees +- **Note** β†’ Project AGENTS.md may be excluded by profile patterns; check your profile's `ocx.jsonc` + +## FORBIDDEN ACTIONS + +- NEVER execute shell commands - you create content, not run commands +- NEVER include AI attribution (no "Generated by Claude", "Co-Authored-By: Claude", etc.) +- NEVER use emojis unless explicitly requested +- NEVER create documentation files unless explicitly requested +- NEVER deviate from the project's existing documentation style +- NEVER spawn or delegate to other agents - you are a leaf agent +- NEVER write code - you write prose about code + +## Output Quality Standards + +- Every piece of content must be immediately usable without editing +- Match the voice and tone of existing project content +- Prioritize clarity over cleverness +- Make content scannable with headers, lists, and code blocks diff --git a/.opencode/commands/review.md b/.opencode/commands/review.md new file mode 100644 index 0000000..16c6807 --- /dev/null +++ b/.opencode/commands/review.md @@ -0,0 +1,21 @@ +--- +description: Run code review on files or recent changes +--- + +Delegate to the `reviewer` agent to perform a code review. + +**Scope:** $ARGUMENTS + +If no arguments provided, review staged changes using `git diff --cached`. +If argument is "recent", review changes since last commit using `git diff HEAD~1`. +Otherwise, review the specified file(s) or directory. + +The reviewer agent will: +- Load the code-review skill +- Apply the 4 Review Layers (Correctness, Security, Performance, Style) +- Classify findings by severity (Critical, Major, Minor, Nitpick) +- Only report findings with >=80% confidence +- Include positive observations +- Provide Philosophy Compliance checklist results + +Return the complete review findings to the user. diff --git a/.opencode/ocx.jsonc b/.opencode/ocx.jsonc new file mode 100644 index 0000000..abbb82f --- /dev/null +++ b/.opencode/ocx.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "https://ocx.kdco.dev/schemas/profile.json", + "registries": { + "kdco": { + "url": "https://registry.kdco.dev" + } + }, + "exclude": [ + "**/CLAUDE.md", + "**/CONTEXT.md", + "**/.opencode/**" + ] +} \ No newline at end of file diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc new file mode 100644 index 0000000..53a655c --- /dev/null +++ b/.opencode/opencode.jsonc @@ -0,0 +1,253 @@ +{ + "$schema":"https://opencode.ai/config.json", + "provider":{ + "azure-foundry":{ + "npm":"@ai-sdk/openai", + "options":{ + "apiKey":"{env:AZURE_OPENAI_API_KEY}", + "baseURL":"https://openslab-resource.openai.azure.com/openai/v1" + }, + "models":{ + "gpt-5.3-codex":{ + "name":"GPT 5.3-codex (Azure)", + "tool_call":true, + "reasoning":true, + "attachment":true, + "limit":{ + "context":400000, + "output":128000 + }, + "options":{ + "reasoningEffort":"xhight", + "textVerbosity":"low" + } + } + } + } + }, + "model":"azure-foundry/gpt-5.3-codex", + "small_model":"azure-foundry/gpt-5.3-codex", + "agent":{ + "plan":{ + "model":"azure-foundry/gpt-5.3-codex", + "temperature":0.3, + "reasoningEffort":"xhight", + "permission":{ + "edit":"deny", + "write":"deny", + "bash":{ + "*":"deny" + }, + "delegate":"allow", + "delegation_read":"allow", + "delegation_list":"allow", + "task":"allow", + "worktree_*":"allow" + } + }, + "build":{ + "model":"azure-foundry/gpt-5.3-codex", + "temperature":0.3, + "reasoningEffort":"xhight", + "prompt":"You are a **build orchestrator**. You coordinate implementation through delegation - you do NOT implement directly.\\n\\n## Your Role\\n- Delegate implementation to `coder`\\n- Delegate documentation to `scribe`\\n- Delegate codebase analysis to `explore`\\n- Delegate external research to `researcher`\\n- Interpret results and decide next steps\\n\\n## Critical Constraint\\nYou CANNOT edit files or run commands directly. For ALL implementation and verification, delegate to `coder`.", + "permission":{ + "edit":"deny", + "write":"deny", + "bash":{ + "*":"deny" + }, + "delegate":"allow", + "task":"allow", + "worktree_*":"allow" + } + }, + "coder":{ + "model":"azure-foundry/gpt-5.3-codex", + "temperature":0.2, + "reasoningEffort":"xhight", + "permission":{ + "context7_*":"deny", + "exa_*":"deny", + "gh_grep_*":"deny", + "read":"allow", + "write":"allow", + "create":"allow", + "edit":"allow", + "glob":"allow", + "grep":"allow", + "bash":"allow", + "plan_read":"deny", + "todoread":"deny" + } + }, + "explore":{ + "model":"azure-foundry/gpt-5.3-codex", + "temperature":0.2, + "reasoningEffort":"xhight", + "permission":{ + "edit":"deny", + "write":"deny", + "plan_read":"deny", + "todoread":"deny", + "bash":{ + "*":"deny", + "ls *":"allow", + "tree *":"allow", + "pwd":"allow", + "cat *":"allow", + "head *":"allow", + "tail *":"allow", + "wc *":"allow", + "file *":"allow", + "stat *":"allow", + "grep *":"allow", + "rg *":"allow", + "find *":"allow", + "git status*":"allow", + "git log*":"allow", + "git diff*":"allow", + "git show*":"allow", + "git blame*":"allow", + "git branch*":"allow", + "git ls-files*":"allow", + "uname*":"allow", + "hostname":"allow", + "whoami":"allow", + "which *":"allow", + "realpath *":"allow" + } + } + }, + "researcher":{ + "model":"azure-foundry/gpt-5.3-codex", + "temperature":0.4, + "reasoningEffort":"xhight", + "permission":{ + "context7_*":"allow", + "exa_*":"allow", + "gh_grep_*":"allow", + "kagi_*":"deny", + "webfetch":"allow", + "write":"deny", + "edit":"deny", + "plan_read":"deny", + "todoread":"deny", + "bash":{ + "*":"deny", + "gh repo view*":"allow", + "gh pr view*":"allow", + "gh pr list*":"allow", + "gh issue view*":"allow", + "gh issue list*":"allow", + "gh release view*":"allow", + "gh release list*":"allow", + "gh run view*":"allow", + "gh run list*":"allow", + "gh workflow list*":"allow", + "gh search *":"allow", + "gh api *":"allow", + "npm view*":"allow", + "npm info*":"allow", + "npm show*":"allow", + "pip show*":"allow", + "pip index*":"allow", + "cargo search*":"allow", + "cargo info*":"allow", + "man *":"allow", + "tldr *":"allow", + "dig *":"allow", + "nslookup *":"allow", + "whois *":"allow", + "host *":"allow", + "jq *":"allow", + "head *":"allow", + "tail *":"allow", + "base64 *":"allow", + "grep *":"allow", + "rg *":"allow", + "wc *":"allow", + "sort *":"allow", + "uniq *":"allow", + "cut *":"allow", + "awk *":"allow", + "tr *":"allow" + } + } + }, + "scribe":{ + "model":"azure-foundry/gpt-5.3-codex", + "temperature":1, + "reasoningEffort":"xhight", + "permission":{ + "bash":{ + "*":"deny" + }, + "edit":"allow", + "glob":"allow", + "read":"allow", + "write":"allow", + "plan_read":"deny", + "todoread":"deny" + } + }, + "reviewer":{ + "model":"azure-foundry/gpt-5.3-codex", + "temperature":0.1, + "reasoningEffort":"xhight", + "permission":{ + "edit":"deny", + "write":"deny", + "bash":{ + "*":"deny", + "git diff*":"allow", + "git log*":"allow", + "git show*":"allow", + "git blame*":"allow", + "rg *":"allow" + }, + "plan_read":"allow" + } + } + }, + "mcp":{ + "context7":{ + "type":"remote", + "url":"https://mcp.context7.com/mcp", + "enabled":true + }, + "exa":{ + "type":"remote", + "url":"https://mcp.exa.ai/mcp", + "enabled":true + }, + "gh_grep":{ + "type":"remote", + "url":"https://mcp.grep.app", + "enabled":true + } + }, + "permission":{ + "task":"deny", + "context7_*":"deny", + "exa_*":"deny", + "gh_grep_*":"deny", + "kagi_*":"deny", + "webfetch":"deny", + "worktree_*":"deny" + }, + "instructions":[ + "./tools/philosophy.md" + ], + "plugin":[ + "@tarquinen/opencode-dcp@3.1.3", + "@franlol/opencode-md-table-formatter@0.0.6", + "./plugins/background-agents.ts", + "./plugins/workspace-plugin.ts" + ], + "watcher":{ + "ignore":[ + ".opencode/**", + ".github/workflows/opencode.yml" + ] + } +} diff --git a/.opencode/plugins/background-agents.ts b/.opencode/plugins/background-agents.ts new file mode 100644 index 0000000..5854d79 --- /dev/null +++ b/.opencode/plugins/background-agents.ts @@ -0,0 +1,1904 @@ +/** + * background-agents + * Unified delegation system for OpenCode + * + * Replaces native `task` tool with persistent, async-first agent delegation. + * All agent outputs are persisted to storage, orchestrator receives only key references. + * + * Based on oh-my-opencode by @code-yeongyu (MIT License) + * https://github.com/code-yeongyu/oh-my-opencode + */ + +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { type Plugin, type ToolContext, tool } from "@opencode-ai/plugin" +import type { Event, Message, Part, TextPart } from "@opencode-ai/sdk" +import { adjectives, animals, colors, uniqueNamesGenerator } from "unique-names-generator" +import { getProjectId } from "./kdco-primitives/get-project-id" +import type { OpencodeClient } from "./kdco-primitives/types" + +// ========================================== +// READABLE ID GENERATION +// ========================================== + +function generateReadableId(): string { + return uniqueNamesGenerator({ + dictionaries: [adjectives, colors, animals], + separator: "-", + length: 3, + style: "lowerCase", + }) +} + +// ========================================== +// METADATA GENERATION (using small_model) +// ========================================== + +interface GeneratedMetadata { + title: string + description: string +} + +/** + * Generate title and description from result content using small_model + * Falls back to truncation if small_model unavailable + */ +async function generateMetadata( + client: OpencodeClient, + resultContent: string, + parentID: string, + debugLog: (msg: string) => Promise, +): Promise { + const fallbackMetadata = (): GeneratedMetadata => { + // Fallback: truncate first line/paragraph + const firstLine = + resultContent.split("\n").find((l) => l.trim().length > 0) || "Delegation result" + const title = firstLine.slice(0, 30).trim() + (firstLine.length > 30 ? "..." : "") + const description = + resultContent.slice(0, 150).trim() + (resultContent.length > 150 ? "..." : "") + return { title, description } + } + + try { + // Get config to check for small_model + const config = await client.config.get() + const configData = config.data as { small_model?: string } | undefined + + if (!configData?.small_model) { + await debugLog("generateMetadata: No small_model configured, using fallback") + return fallbackMetadata() + } + + await debugLog(`generateMetadata: Using small_model ${configData.small_model}`) + + // Create a session for metadata generation + const session = await client.session.create({ + body: { + title: "Metadata Generation", + parentID, + }, + }) + + if (!session.data?.id) { + await debugLog("generateMetadata: Failed to create session") + return fallbackMetadata() + } + + // Prompt the small model for metadata + const prompt = `Generate a title and description for this research result. + +RULES: +- Title: 2-5 words, max 30 characters, sentence case +- Description: 2-3 sentences, max 150 characters, summarize key findings + +RESULT CONTENT: +${resultContent.slice(0, 2000)} + +Respond with ONLY valid JSON in this exact format: +{"title": "Your Title Here", "description": "Your description here."}` + + // Await prompt response directly with timeout safety net + const PROMPT_TIMEOUT_MS = 30000 + const result = await Promise.race([ + client.session.prompt({ + path: { id: session.data.id }, + body: { + parts: [{ type: "text", text: prompt }], + }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Prompt timeout after 30s")), PROMPT_TIMEOUT_MS), + ), + ]) + + // Extract text from the response + const responseParts = result.data?.parts as TextPart[] | undefined + const textPart = responseParts?.find((p): p is TextPart => p.type === "text") + if (!textPart) { + await debugLog("generateMetadata: No text part in response") + return fallbackMetadata() + } + + // Parse JSON response + const jsonMatch = textPart.text.match(/\{[\s\S]*\}/) + if (!jsonMatch) { + await debugLog(`generateMetadata: No JSON found in response: ${textPart.text}`) + return fallbackMetadata() + } + + const parsed = JSON.parse(jsonMatch[0]) as { title?: string; description?: string } + if (!parsed.title || !parsed.description) { + await debugLog("generateMetadata: Invalid JSON structure") + return fallbackMetadata() + } + + await debugLog(`generateMetadata: Generated title="${parsed.title}"`) + return { + title: parsed.title.slice(0, 30), + description: parsed.description.slice(0, 150), + } + } catch (error) { + await debugLog( + `generateMetadata error: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + return fallbackMetadata() + } +} + +// ========================================== +// TYPE DEFINITIONS +// ========================================== + +interface SessionMessageItem { + info: Message + parts: Part[] +} + +interface AssistantSessionMessageItem { + info: Message & { role: "assistant" } + parts: Part[] +} + +type DelegationStatus = "registered" | "running" | "complete" | "error" | "cancelled" | "timeout" + +type DelegationTerminalStatus = Extract< + DelegationStatus, + "complete" | "error" | "cancelled" | "timeout" +> + +interface DelegationProgress { + toolCalls: number + lastUpdateAt: Date + lastHeartbeatAt: Date + lastMessage?: string + lastMessageAt?: Date +} + +interface DelegationNotificationState { + terminalNotifiedAt?: Date + terminalNotificationCount: number +} + +interface ParentNotificationState { + allCompleteNotifiedAt?: Date + allCompleteNotificationCount: number + allCompleteCycle: number + allCompleteCycleToken: string + allCompleteNotifiedCycle?: number + allCompleteNotifiedCycleToken?: string + allCompleteScheduledCycle?: number + allCompleteScheduledCycleToken?: string + allCompleteScheduledTimer?: ReturnType +} + +interface DelegationRetrievalState { + retrievedAt?: Date + retrievalCount: number + lastReaderSessionID?: string +} + +interface DelegationArtifactState { + filePath: string + persistedAt?: Date + byteLength?: number + persistError?: string +} + +interface DelegationRecord { + id: string + rootSessionID: string + sessionID: string + parentSessionID: string + parentMessageID: string + parentAgent: string + prompt: string + agent: string + notificationCycle: number + notificationCycleToken: string + status: DelegationStatus + createdAt: Date + startedAt?: Date + completedAt?: Date + updatedAt: Date + timeoutAt: Date + progress: DelegationProgress + notification: DelegationNotificationState + retrieval: DelegationRetrievalState + artifact: DelegationArtifactState + error?: string + title?: string + description?: string + result?: string +} + +const DEFAULT_MAX_RUN_TIME_MS = 15 * 60 * 1000 // 15 minutes +const TERMINAL_WAIT_GRACE_MS = 10_000 +const READ_POLL_INTERVAL_MS = 250 +const ALL_COMPLETE_QUIET_PERIOD_MS = 50 + +interface DelegateInput { + parentSessionID: string + parentMessageID: string + parentAgent: string + prompt: string + agent: string +} + +interface DelegationListItem { + id: string + status: DelegationStatus + title?: string + description?: string + agent?: string + unread?: boolean +} + +interface DelegationManagerOptions { + maxRunTimeMs?: number + readPollIntervalMs?: number + terminalWaitGraceMs?: number + allCompleteQuietPeriodMs?: number + idGenerator?: () => string + metadataGenerator?: typeof generateMetadata +} + +// ========================================== +// LOGGING HELPER +// ========================================== + +/** + * Create a structured logger that sends messages to OpenCode's log API. + * Catches errors silently to avoid disrupting tool execution. + */ +function createLogger(client: OpencodeClient) { + const log = (level: "debug" | "info" | "warn" | "error", message: string) => + client.app.log({ body: { service: "background-agents", level, message } }).catch(() => {}) + return { + debug: (msg: string) => log("debug", msg), + info: (msg: string) => log("info", msg), + warn: (msg: string) => log("warn", msg), + error: (msg: string) => log("error", msg), + } +} + +type Logger = ReturnType + +// ========================================== +// AGENT CAPABILITY DETECTION +// ========================================== + +/** + * Parse agent mode at boundary. + * Returns trusted type indicating if agent is a sub-agent. + */ +async function parseAgentMode( + client: OpencodeClient, + agentName: string, + log: Logger, +): Promise<{ isSubAgent: boolean }> { + try { + const result = await client.app.agents({}) + const agents = (result.data ?? []) as { name: string; mode?: string }[] + const agent = agents.find((a) => a.name === agentName) + return { isSubAgent: agent?.mode === "subagent" } + } catch (error) { + // Fail-safe: Agent list errors shouldn't block task calls + // Fail-loud: Log for observability + log.warn( + `Agent list fetch failed for "${agentName}", assuming non-sub-agent: ${error instanceof Error ? error.message : String(error)}`, + ) + return { isSubAgent: false } + } +} + +/** + * Permission entry type: simple value or pattern object. + * Matches CLI schema: z.union([z.enum(["ask", "allow", "deny"]), z.record(z.enum(...))]) + */ +type PermissionEntry = "ask" | "allow" | "deny" | Record + +/** + * Check if a permission entry denies access (Law 4: Fail Fast). + * Handles both simple values ("deny") and pattern objects ({ "*": "deny" }). + */ +function isPermissionDenied(entry: PermissionEntry | undefined): boolean { + if (entry === undefined) return false + if (entry === "deny") return true + if (typeof entry === "object" && entry["*"] === "deny") return true + return false +} + +/** + * Parse agent write capability at boundary. + * Returns trusted type indicating if agent is read-only. + * + * An agent is read-only when ALL of: edit, write, and bash are denied. + * Permission schema supports both simple ("deny") and pattern ({ "*": "deny" }) values. + */ +async function parseAgentWriteCapability( + client: OpencodeClient, + agentName: string, + log: Logger, +): Promise<{ isReadOnly: boolean }> { + try { + const config = await client.config.get() + const configData = config.data as { + agent?: Record< + string, + { + permission?: Record + } + > + } + const permission = configData?.agent?.[agentName]?.permission ?? {} + + const editDenied = isPermissionDenied(permission.edit) + const writeDenied = isPermissionDenied(permission.write) + const bashDenied = isPermissionDenied(permission.bash) + + return { isReadOnly: editDenied && writeDenied && bashDenied } + } catch (error) { + // Fail-safe: Config errors shouldn't block task calls + // Fail-loud: Log for observability + log.warn( + `Config fetch failed for "${agentName}", assuming write-capable: ${error instanceof Error ? error.message : String(error)}`, + ) + return { isReadOnly: false } + } +} + +/** + * DELEGATION MANAGER + */ +function isTerminalStatus(status: DelegationStatus): status is DelegationTerminalStatus { + return ( + status === "complete" || status === "error" || status === "cancelled" || status === "timeout" + ) +} + +function isActiveStatus(status: DelegationStatus): boolean { + return status === "registered" || status === "running" +} + +function normalizeId(value: string): string { + return value.trim() +} + +function parsePersistedStatus(raw: string | undefined): DelegationStatus { + if (!raw) return "complete" + if (raw === "registered") return "registered" + if (raw === "running") return "running" + if (raw === "complete") return "complete" + if (raw === "error") return "error" + if (raw === "cancelled") return "cancelled" + if (raw === "timeout") return "timeout" + return "complete" +} + +class DelegationManager { + private delegations: Map = new Map() + private delegationsBySession: Map = new Map() + private terminalWaiters: Map; resolve: () => void }> = new Map() + private timeoutTimers: Map> = new Map() + private client: OpencodeClient + private baseDir: string + private log: Logger + private maxRunTimeMs: number + private readPollIntervalMs: number + private terminalWaitGraceMs: number + private allCompleteQuietPeriodMs: number + private idGenerator: () => string + private metadataGenerator: typeof generateMetadata + private pendingByParent: Map> = new Map() + private parentNotificationState: Map = new Map() + + constructor( + client: OpencodeClient, + baseDir: string, + log: Logger, + options: DelegationManagerOptions = {}, + ) { + this.client = client + this.baseDir = baseDir + this.log = log + this.maxRunTimeMs = options.maxRunTimeMs ?? DEFAULT_MAX_RUN_TIME_MS + this.readPollIntervalMs = options.readPollIntervalMs ?? READ_POLL_INTERVAL_MS + this.terminalWaitGraceMs = options.terminalWaitGraceMs ?? TERMINAL_WAIT_GRACE_MS + this.allCompleteQuietPeriodMs = options.allCompleteQuietPeriodMs ?? ALL_COMPLETE_QUIET_PERIOD_MS + this.idGenerator = options.idGenerator ?? generateReadableId + this.metadataGenerator = options.metadataGenerator ?? generateMetadata + } + + /** + * Resolves the root session ID by walking up the parent chain. + */ + async getRootSessionID(sessionID: string): Promise { + let currentID = sessionID + // Prevent infinite loops with max depth + for (let depth = 0; depth < 10; depth++) { + try { + const session = await this.client.session.get({ + path: { id: currentID }, + }) + + if (!session.data?.parentID) { + return currentID + } + + currentID = session.data.parentID + } catch { + // If we can't fetch the session, assume current is root or best effort + return currentID + } + } + return currentID + } + + /** + * Get the delegations directory for a session scope (root session) + */ + private async getDelegationsDir(sessionID: string): Promise { + const rootID = await this.getRootSessionID(sessionID) + return path.join(this.baseDir, rootID) + } + + /** + * Ensure the delegations directory exists + */ + private async ensureDelegationsDir(sessionID: string): Promise { + const dir = await this.getDelegationsDir(sessionID) + await fs.mkdir(dir, { recursive: true }) + return dir + } + + private createTerminalWaiter(id: string): void { + if (this.terminalWaiters.has(id)) return + + let resolve: (() => void) | undefined + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + + if (!resolve) { + throw new Error(`Failed to initialize terminal waiter for delegation ${id}`) + } + + this.terminalWaiters.set(id, { promise, resolve }) + } + + private resolveTerminalWaiter(id: string): void { + const waiter = this.terminalWaiters.get(id) + if (!waiter) return + waiter.resolve() + } + + private clearTimeoutTimer(id: string): void { + const timer = this.timeoutTimers.get(id) + if (!timer) return + clearTimeout(timer) + this.timeoutTimers.delete(id) + } + + private scheduleTimeout(id: string): void { + this.clearTimeoutTimer(id) + const timer = setTimeout(() => { + void this.handleTimeout(id) + }, this.maxRunTimeMs + 5_000) + this.timeoutTimers.set(id, timer) + } + + private updateDelegation( + id: string, + mutate: (delegation: DelegationRecord, now: Date) => void, + ): DelegationRecord | undefined { + const delegation = this.delegations.get(id) + if (!delegation) return undefined + + const now = new Date() + mutate(delegation, now) + delegation.updatedAt = now + return delegation + } + + private registerDelegation(input: { + id: string + rootSessionID: string + sessionID: string + parentSessionID: string + parentMessageID: string + parentAgent: string + prompt: string + agent: string + artifactPath: string + }): DelegationRecord { + if (!this.pendingByParent.has(input.parentSessionID)) { + this.pendingByParent.set(input.parentSessionID, new Set()) + this.resetParentAllCompleteNotificationCycle(input.parentSessionID) + } + + const parentNotificationState = this.getParentNotificationState(input.parentSessionID) + const notificationCycle = parentNotificationState.allCompleteCycle + const notificationCycleToken = parentNotificationState.allCompleteCycleToken + + const now = new Date() + const delegation: DelegationRecord = { + id: input.id, + rootSessionID: input.rootSessionID, + sessionID: input.sessionID, + parentSessionID: input.parentSessionID, + parentMessageID: input.parentMessageID, + parentAgent: input.parentAgent, + prompt: input.prompt, + agent: input.agent, + notificationCycle, + notificationCycleToken, + status: "registered", + createdAt: now, + updatedAt: now, + timeoutAt: new Date(now.getTime() + this.maxRunTimeMs), + progress: { + toolCalls: 0, + lastUpdateAt: now, + lastHeartbeatAt: now, + }, + notification: { + terminalNotificationCount: 0, + }, + retrieval: { + retrievalCount: 0, + }, + artifact: { + filePath: input.artifactPath, + }, + } + + this.delegations.set(delegation.id, delegation) + this.delegationsBySession.set(delegation.sessionID, delegation.id) + this.createTerminalWaiter(delegation.id) + this.pendingByParent.get(delegation.parentSessionID)?.add(delegation.id) + + return delegation + } + + private markStarted(id: string): DelegationRecord | undefined { + return this.updateDelegation(id, (delegation, now) => { + if (isTerminalStatus(delegation.status)) return + delegation.status = "running" + delegation.startedAt = now + delegation.progress.lastUpdateAt = now + delegation.progress.lastHeartbeatAt = now + }) + } + + private markProgress(id: string, messageText?: string): DelegationRecord | undefined { + return this.updateDelegation(id, (delegation, now) => { + if (isTerminalStatus(delegation.status)) return + if (delegation.status === "registered") { + delegation.status = "running" + delegation.startedAt = delegation.startedAt ?? now + } + + delegation.progress.lastUpdateAt = now + delegation.progress.lastHeartbeatAt = now + + if (messageText) { + delegation.progress.lastMessage = messageText + delegation.progress.lastMessageAt = now + } + }) + } + + private markTerminal( + id: string, + status: DelegationTerminalStatus, + error?: string, + ): { transitioned: boolean; delegation?: DelegationRecord } { + const delegation = this.delegations.get(id) + if (!delegation) return { transitioned: false } + + if (isTerminalStatus(delegation.status)) { + return { transitioned: false, delegation } + } + + const now = new Date() + delegation.status = status + delegation.completedAt = now + delegation.updatedAt = now + if (error) { + delegation.error = error + } + + const pending = this.pendingByParent.get(delegation.parentSessionID) + if (pending) { + pending.delete(delegation.id) + if (pending.size === 0) { + this.pendingByParent.delete(delegation.parentSessionID) + } + } + + this.clearTimeoutTimer(id) + this.resolveTerminalWaiter(id) + + return { transitioned: true, delegation } + } + + private markNotified(id: string): DelegationRecord | undefined { + return this.updateDelegation(id, (delegation, now) => { + delegation.notification.terminalNotifiedAt = now + delegation.notification.terminalNotificationCount += 1 + }) + } + + private getParentNotificationState(parentSessionID: string): ParentNotificationState { + const existing = this.parentNotificationState.get(parentSessionID) + if (existing) return existing + + const initialized: ParentNotificationState = { + allCompleteNotificationCount: 0, + allCompleteCycle: 0, + allCompleteCycleToken: this.buildAllCompleteCycleToken(parentSessionID, 0), + } + this.parentNotificationState.set(parentSessionID, initialized) + return initialized + } + + private buildAllCompleteCycleToken(parentSessionID: string, cycle: number): string { + return `${parentSessionID}:${cycle}` + } + + private resetParentAllCompleteNotificationCycle(parentSessionID: string): void { + const state = this.getParentNotificationState(parentSessionID) + this.cancelScheduledAllComplete(state) + state.allCompleteCycle += 1 + state.allCompleteCycleToken = this.buildAllCompleteCycleToken( + parentSessionID, + state.allCompleteCycle, + ) + state.allCompleteNotifiedAt = undefined + state.allCompleteNotifiedCycle = undefined + state.allCompleteNotifiedCycleToken = undefined + } + + private cancelScheduledAllComplete(state: ParentNotificationState): void { + if (state.allCompleteScheduledTimer) { + clearTimeout(state.allCompleteScheduledTimer) + } + state.allCompleteScheduledTimer = undefined + state.allCompleteScheduledCycle = undefined + state.allCompleteScheduledCycleToken = undefined + } + + private areCycleTerminalNotificationsComplete( + parentSessionID: string, + cycleToken: string, + ): boolean { + let cycleDelegationCount = 0 + + for (const delegation of this.delegations.values()) { + if (delegation.parentSessionID !== parentSessionID) continue + if (delegation.notificationCycleToken !== cycleToken) continue + + cycleDelegationCount += 1 + if (!delegation.notification.terminalNotifiedAt) { + return false + } + } + + return cycleDelegationCount > 0 + } + + private scheduleAllCompleteForParent(parentSessionID: string, parentAgent: string): void { + const state = this.getParentNotificationState(parentSessionID) + const cycle = state.allCompleteCycle + const cycleToken = state.allCompleteCycleToken + if (!this.areCycleTerminalNotificationsComplete(parentSessionID, cycleToken)) return + + if (state.allCompleteNotifiedCycleToken === cycleToken) return + if (state.allCompleteScheduledCycleToken === cycleToken) return + + this.cancelScheduledAllComplete(state) + + state.allCompleteScheduledCycle = cycle + state.allCompleteScheduledCycleToken = cycleToken + state.allCompleteScheduledTimer = setTimeout(() => { + void this.dispatchScheduledAllComplete(parentSessionID, parentAgent, cycle, cycleToken) + }, this.allCompleteQuietPeriodMs) + } + + private async dispatchScheduledAllComplete( + parentSessionID: string, + parentAgent: string, + cycle: number, + cycleToken: string, + ): Promise { + const state = this.getParentNotificationState(parentSessionID) + + if (state.allCompleteScheduledCycleToken !== cycleToken) return + + this.cancelScheduledAllComplete(state) + + if (state.allCompleteCycleToken !== cycleToken) return + if (!this.areCycleTerminalNotificationsComplete(parentSessionID, cycleToken)) return + if (state.allCompleteNotifiedCycleToken === cycleToken) return + + try { + await this.client.session.prompt({ + path: { id: parentSessionID }, + body: { + noReply: false, + agent: parentAgent, + parts: [ + { + type: "text", + text: this.buildAllCompleteNotification(parentSessionID, cycle, cycleToken), + }, + ], + }, + }) + } catch (error) { + await this.debugLog( + `all-complete notification failed for ${parentSessionID} cycle=${cycleToken}: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ) + return + } + + if (state.allCompleteCycleToken !== cycleToken) return + if (!this.areCycleTerminalNotificationsComplete(parentSessionID, cycleToken)) return + + state.allCompleteNotifiedAt = new Date() + state.allCompleteNotificationCount += 1 + state.allCompleteNotifiedCycle = cycle + state.allCompleteNotifiedCycleToken = cycleToken + } + + private markRetrieved(id: string, readerSessionID: string): DelegationRecord | undefined { + return this.updateDelegation(id, (delegation, now) => { + delegation.retrieval.retrievedAt = now + delegation.retrieval.retrievalCount += 1 + delegation.retrieval.lastReaderSessionID = readerSessionID + }) + } + + private hasUnreadCompletion(delegation: DelegationRecord): boolean { + if (!isTerminalStatus(delegation.status)) return false + if (!delegation.notification.terminalNotifiedAt) return false + if (!delegation.completedAt) return false + + if (!delegation.retrieval.retrievedAt) return true + return delegation.retrieval.retrievedAt.getTime() < delegation.completedAt.getTime() + } + + private async waitForTerminal(id: string, timeoutMs: number): Promise<"terminal" | "timeout"> { + const delegation = this.delegations.get(id) + if (!delegation) return "timeout" + if (isTerminalStatus(delegation.status)) return "terminal" + + const waiter = this.terminalWaiters.get(id) + if (!waiter) return "timeout" + + let timer: ReturnType | undefined + try { + const result = await Promise.race<"terminal" | "timeout">([ + waiter.promise.then(() => "terminal"), + new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), timeoutMs) + }), + ]) + return result + } finally { + if (timer) clearTimeout(timer) + } + } + + private async generateUniqueDelegationId(artifactDir: string): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const candidate = this.idGenerator() + if (this.delegations.has(candidate)) continue + + const candidatePath = path.join(artifactDir, `${candidate}.md`) + try { + await fs.access(candidatePath) + } catch { + return candidate + } + } + + throw new Error("Failed to generate unique delegation ID after 20 attempts") + } + + private getDelegationBySession(sessionID: string): DelegationRecord | undefined { + const delegationId = this.delegationsBySession.get(sessionID) + if (!delegationId) return undefined + return this.delegations.get(delegationId) + } + + private isVisibleToSession(delegation: DelegationRecord, rootSessionID: string): boolean { + return delegation.rootSessionID === rootSessionID + } + + private buildTerminalNotification(delegation: DelegationRecord, remainingCount: number): string { + const lines = [ + "", + `${delegation.id}`, + `${delegation.status}`, + `Background agent ${delegation.status}: ${delegation.title || delegation.id}`, + delegation.title ? `${delegation.title}` : "", + delegation.description ? `${delegation.description}` : "", + delegation.error ? `${delegation.error}` : "", + `${delegation.artifact.filePath}`, + `Use delegation_read("${delegation.id}") for full output.`, + remainingCount > 0 ? `${remainingCount}` : "", + "", + ] + + return lines.filter((line) => line.length > 0).join("\n") + } + + private buildAllCompleteNotification( + parentSessionID: string, + cycle: number, + cycleToken: string, + ): string { + // cycle-token is a boundary watermark. + // Receivers should ignore all-complete payloads whose token is older than + // the latest known registration cycle for this parent session. + return [ + "", + "all-complete", + "completed", + "All delegations complete.", + `${parentSessionID}`, + `${cycle}`, + `${cycleToken}`, + "", + ].join("\n") + } + + private buildDeterministicTerminalReadResponse(delegation: DelegationRecord): string { + const lines = [ + `Delegation ID: ${delegation.id}`, + `Status: ${delegation.status}`, + `Agent: ${delegation.agent}`, + `Started: ${delegation.startedAt?.toISOString() || delegation.createdAt.toISOString()}`, + `Completed: ${delegation.completedAt?.toISOString() || "N/A"}`, + `Artifact: ${delegation.artifact.filePath}`, + ] + + if (delegation.title) lines.push(`Title: ${delegation.title}`) + if (delegation.description) lines.push(`Description: ${delegation.description}`) + if (delegation.error) lines.push(`Error: ${delegation.error}`) + + lines.push(`\nUse delegation_read("${delegation.id}") again after persistence completes.`) + return lines.join("\n") + } + + private async readPersistedArtifact(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf8") + } catch { + return null + } + } + + private async waitForPersistedArtifact( + filePath: string, + maxWaitMs: number, + ): Promise { + const start = Date.now() + while (Date.now() - start < maxWaitMs) { + const content = await this.readPersistedArtifact(filePath) + if (content !== null) return content + await new Promise((resolve) => setTimeout(resolve, this.readPollIntervalMs)) + } + + return null + } + + private async resolveDelegationResult(delegation: DelegationRecord): Promise { + if (delegation.status === "error") { + return `Error: ${delegation.error || "Delegation failed."}` + } + + if (delegation.status === "cancelled") { + return "Delegation was cancelled before completion." + } + + if (delegation.status === "timeout") { + const partial = await this.getResult(delegation) + return `${partial}\n\n[TIMEOUT REACHED]` + } + + return await this.getResult(delegation) + } + + private async finalizeDelegation( + delegationId: string, + status: DelegationTerminalStatus, + error?: string, + ): Promise { + const { transitioned, delegation } = this.markTerminal(delegationId, status, error) + if (!transitioned || !delegation) return + + await this.debugLog(`finalizeDelegation(${delegation.id}, ${status}) started`) + + const resolvedResult = await this.resolveDelegationResult(delegation) + delegation.result = resolvedResult + + if (resolvedResult.trim().length > 0) { + const metadata = await this.metadataGenerator( + this.client, + resolvedResult, + delegation.sessionID, + (msg) => this.debugLog(msg), + ) + delegation.title = metadata.title + delegation.description = metadata.description + } + + await this.persistOutput(delegation, resolvedResult) + await this.notifyParent(delegation.id) + } + + private async notifyParent(delegationId: string): Promise { + try { + const delegation = this.delegations.get(delegationId) + if (!delegation) return + if (!isTerminalStatus(delegation.status)) return + if (delegation.notification.terminalNotifiedAt) { + await this.debugLog(`notifyParent skipped for ${delegation.id}; already notified`) + return + } + + const remainingCount = this.getPendingCount(delegation.parentSessionID) + const terminalNotification = this.buildTerminalNotification(delegation, remainingCount) + + await this.client.session.prompt({ + path: { id: delegation.parentSessionID }, + body: { + noReply: true, + agent: delegation.parentAgent, + parts: [{ type: "text", text: terminalNotification }], + }, + }) + + this.markNotified(delegation.id) + this.scheduleAllCompleteForParent(delegation.parentSessionID, delegation.parentAgent) + + await this.debugLog( + `notifyParent sent for ${delegation.id} (remaining=${remainingCount}, status=${delegation.status})`, + ) + } catch (error) { + await this.debugLog( + `notifyParent failed for ${delegationId}: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + } + + /** + * Delegate a task to an agent + */ + async delegate(input: DelegateInput): Promise { + // Validate agent exists before creating session + const agentsResult = await this.client.app.agents({}) + const agents = (agentsResult.data ?? []) as { + name: string + description?: string + mode?: string + }[] + const validAgent = agents.find((a) => a.name === input.agent) + + if (!validAgent) { + const available = agents + .filter((a) => a.mode === "subagent" || a.mode === "all" || !a.mode) + .map((a) => `β€’ ${a.name}${a.description ? ` - ${a.description}` : ""}`) + .join("\n") + + throw new Error( + `Agent "${input.agent}" not found.\n\nAvailable agents:\n${available || "(none)"}`, + ) + } + + // Check if agent is read-only (Early Exit + Fail Fast) + const { isReadOnly } = await parseAgentWriteCapability(this.client, input.agent, this.log) + if (!isReadOnly) { + throw new Error( + `Agent "${input.agent}" is write-capable and requires the native \`task\` tool for proper undo/branching support.\n\n` + + `Use \`task\` instead of \`delegate\` for write-capable agents.\n\n` + + `Read-only sub-agents (edit/write/bash denied) use \`delegate\`.\n` + + `Write-capable sub-agents (any write permission) use \`task\`.`, + ) + } + + const artifactDir = await this.ensureDelegationsDir(input.parentSessionID) + const rootSessionID = await this.getRootSessionID(input.parentSessionID) + const stableId = await this.generateUniqueDelegationId(artifactDir) + const artifactPath = path.join(artifactDir, `${stableId}.md`) + + await this.debugLog(`delegate() called, generated stable ID: ${stableId}`) + + // Create isolated session for delegation + const sessionResult = await this.client.session.create({ + body: { + title: `Delegation: ${stableId}`, + parentID: input.parentSessionID, + }, + }) + + await this.debugLog(`session.create result: ${JSON.stringify(sessionResult.data)}`) + + if (!sessionResult.data?.id) { + throw new Error("Failed to create delegation session") + } + + const delegation = this.registerDelegation({ + id: stableId, + rootSessionID, + sessionID: sessionResult.data.id, + parentSessionID: input.parentSessionID, + parentMessageID: input.parentMessageID, + parentAgent: input.parentAgent, + prompt: input.prompt, + agent: input.agent, + artifactPath, + }) + + await this.debugLog(`Registered delegation ${delegation.id} before execution`) + this.scheduleTimeout(delegation.id) + this.markStarted(delegation.id) + + // Fire the prompt (using prompt() instead of promptAsync() to properly initialize agent loop) + // Agent param is critical for MCP tools - tells OpenCode which agent's config to use + // Anti-recursion: disable nested delegations and state-modifying tools via tools config + this.client.session + .prompt({ + path: { id: delegation.sessionID }, + body: { + agent: input.agent, + parts: [{ type: "text", text: input.prompt }], + tools: { + task: false, + delegate: false, + todowrite: false, + plan_save: false, + }, + }, + }) + .catch((error: Error) => { + void this.finalizeDelegation(delegation.id, "error", error.message) + }) + + return delegation + } + + /** + * Handle delegation timeout + */ + private async handleTimeout(delegationId: string): Promise { + const delegation = this.delegations.get(delegationId) + if (!delegation || isTerminalStatus(delegation.status)) return + + await this.debugLog(`handleTimeout for delegation ${delegation.id}`) + + // Try to cancel the session + try { + await this.client.session.delete({ + path: { id: delegation.sessionID }, + }) + } catch { + // Ignore + } + + await this.finalizeDelegation( + delegation.id, + "timeout", + `Delegation timed out after ${this.maxRunTimeMs / 1000}s`, + ) + } + + /** + * Handle session.idle event - called when a session becomes idle + */ + async handleSessionIdle(sessionID: string): Promise { + const delegation = this.findBySession(sessionID) + if (!delegation || isTerminalStatus(delegation.status)) return + + await this.debugLog(`handleSessionIdle for delegation ${delegation.id}`) + await this.finalizeDelegation(delegation.id, "complete") + } + + /** + * Get the result from a delegation's session + */ + private async getResult(delegation: DelegationRecord): Promise { + try { + const messages = await this.client.session.messages({ + path: { id: delegation.sessionID }, + }) + + const messageData = messages.data as SessionMessageItem[] | undefined + + if (!messageData || messageData.length === 0) { + await this.debugLog(`getResult: No messages found for session ${delegation.sessionID}`) + return `Delegation "${delegation.description}" completed but produced no output.` + } + + await this.debugLog( + `getResult: Found ${messageData.length} messages. Roles: ${messageData.map((m) => m.info.role).join(", ")}`, + ) + + // Find the last message from the assistant/model + const isAssistantMessage = (m: SessionMessageItem): m is AssistantSessionMessageItem => + m.info.role === "assistant" + + const assistantMessages = messageData.filter(isAssistantMessage) + + if (assistantMessages.length === 0) { + await this.debugLog( + `getResult: No assistant messages found in ${JSON.stringify(messageData.map((m) => ({ role: m.info.role, keys: Object.keys(m) })))}`, + ) + return `Delegation "${delegation.description}" completed but produced no assistant response.` + } + + const lastMessage = assistantMessages[assistantMessages.length - 1] + + // Extract text parts from the message + const isTextPart = (p: Part): p is TextPart => p.type === "text" + const textParts = lastMessage.parts.filter(isTextPart) + + if (textParts.length === 0) { + await this.debugLog( + `getResult: No text parts found in message: ${JSON.stringify(lastMessage)}`, + ) + return `Delegation "${delegation.description}" completed but produced no text content.` + } + + return textParts.map((p) => p.text).join("\n") + } catch (error) { + await this.debugLog( + `getResult error: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + return `Delegation "${delegation.description}" completed but result could not be retrieved: ${ + error instanceof Error ? error.message : "Unknown error" + }` + } + } + + /** + * Persist delegation output to storage + */ + private async persistOutput(delegation: DelegationRecord, content: string): Promise { + try { + // Use title/description if available (generated by small model), otherwise fallback + const title = delegation.title || delegation.id + const description = delegation.description || "(No description generated)" + + const header = `# ${title} + +${description} + +**ID:** ${delegation.id} +**Agent:** ${delegation.agent} +**Status:** ${delegation.status} +**Session:** ${delegation.sessionID} +**Started:** ${(delegation.startedAt || delegation.createdAt).toISOString()} +**Completed:** ${delegation.completedAt?.toISOString() || "N/A"} + +--- + +` + await fs.writeFile(delegation.artifact.filePath, header + content, "utf8") + + const stats = await fs.stat(delegation.artifact.filePath) + this.updateDelegation(delegation.id, (record, now) => { + record.artifact.persistedAt = now + record.artifact.byteLength = stats.size + record.artifact.persistError = undefined + }) + + await this.debugLog(`Persisted output to ${delegation.artifact.filePath}`) + } catch (error) { + this.updateDelegation(delegation.id, (record) => { + record.artifact.persistError = + error instanceof Error ? error.message : "Unknown persistence error" + }) + await this.debugLog( + `Failed to persist output: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } + } + + /** + * Read a delegation's output by ID. Blocks if the delegation is still running. + */ + async readOutput(sessionID: string, id: string): Promise { + const normalizedId = normalizeId(id) + if (!normalizedId) { + throw new Error("Delegation ID is required") + } + + const rootSessionID = await this.getRootSessionID(sessionID) + let delegation = this.delegations.get(normalizedId) + if (delegation && !this.isVisibleToSession(delegation, rootSessionID)) { + delegation = undefined + } + + const fallbackFilePath = path.join( + await this.getDelegationsDir(sessionID), + `${normalizedId}.md`, + ) + + const immediateArtifactPath = delegation?.artifact.filePath || fallbackFilePath + const immediateRead = await this.readPersistedArtifact(immediateArtifactPath) + if (immediateRead !== null) { + if (delegation) this.markRetrieved(delegation.id, sessionID) + return immediateRead + } + + if (!delegation) { + throw new Error( + `Delegation "${normalizedId}" not found.\n\nUse delegation_list() to see available delegations.`, + ) + } + + if (isActiveStatus(delegation.status)) { + const remainingMs = Math.max( + delegation.timeoutAt.getTime() - Date.now() + this.terminalWaitGraceMs, + this.readPollIntervalMs, + ) + + await this.debugLog( + `readOutput: waiting up to ${remainingMs}ms for delegation ${delegation.id} to reach terminal state`, + ) + + const waitResult = await this.waitForTerminal(delegation.id, remainingMs) + if (waitResult === "timeout" && isActiveStatus(delegation.status)) { + await this.handleTimeout(delegation.id) + } + } + + if (isTerminalStatus(delegation.status)) { + const delayedPersisted = await this.waitForPersistedArtifact( + delegation.artifact.filePath, + Math.max(this.readPollIntervalMs * 8, 500), + ) + if (delayedPersisted !== null) { + this.markRetrieved(delegation.id, sessionID) + return delayedPersisted + } + } + + const persisted = await this.readPersistedArtifact(delegation.artifact.filePath) + if (persisted !== null) { + this.markRetrieved(delegation.id, sessionID) + return persisted + } + + if (isTerminalStatus(delegation.status)) { + return this.buildDeterministicTerminalReadResponse(delegation) + } + + return `Delegation "${delegation.id}" is still running. You will receive a when it reaches a terminal state.` + } + + /** + * List all delegations for a session + */ + async listDelegations(sessionID: string): Promise { + const rootSessionID = await this.getRootSessionID(sessionID) + const results: DelegationListItem[] = [] + + // Add in-memory delegations in this root session scope + for (const delegation of this.delegations.values()) { + if (!this.isVisibleToSession(delegation, rootSessionID)) continue + + results.push({ + id: delegation.id, + status: delegation.status, + title: delegation.title || delegation.id, + description: + delegation.description || + (delegation.status === "running" || delegation.status === "registered" + ? "(running)" + : "(no description)"), + agent: delegation.agent, + unread: this.hasUnreadCompletion(delegation), + }) + } + + // Check filesystem for persisted delegations + try { + const dir = await this.getDelegationsDir(rootSessionID) + const files = await fs.readdir(dir) + + for (const file of files) { + if (file.endsWith(".md")) { + const id = file.replace(".md", "") + // Deduplicate: prioritize in-memory status + if (!results.find((r) => r.id === id)) { + // Try to read title, agent, description from file + let title = "(loaded from storage)" + let description = "" + let agent: string | undefined + let status: DelegationStatus = "complete" + try { + const filePath = path.join(dir, file) + const content = await fs.readFile(filePath, "utf8") + const titleMatch = content.match(/^# (.+)$/m) + if (titleMatch) title = titleMatch[1] + const agentMatch = content.match(/^\*\*Agent:\*\* (.+)$/m) + if (agentMatch) agent = agentMatch[1] + const statusMatch = content.match(/^\*\*Status:\*\* (.+)$/m) + status = parsePersistedStatus(statusMatch?.[1]?.trim()) + // Get first paragraph after title as description + const lines = content.split("\n") + if (lines.length > 2 && lines[2]) { + description = lines[2].slice(0, 150) + } + } catch { + // Ignore read errors + } + results.push({ + id, + status, + title, + description, + agent, + unread: false, + }) + } + } + } + } catch { + // Directory may not exist yet + } + + results.sort((a, b) => a.id.localeCompare(b.id)) + return results + } + + /** + * Delete a delegation by id (cancels if running, removes from storage) + * Used internally for cleanup (timeout, etc.) + */ + async deleteDelegation(sessionID: string, id: string): Promise { + const normalizedId = normalizeId(id) + const delegation = this.delegations.get(normalizedId) + + if (delegation) { + if (isActiveStatus(delegation.status)) { + try { + await this.client.session.delete({ + path: { id: delegation.sessionID }, + }) + } catch { + // Session may already be deleted + } + this.markTerminal(delegation.id, "cancelled", "Delegation deleted by cleanup") + } + + this.clearTimeoutTimer(delegation.id) + this.terminalWaiters.delete(delegation.id) + this.delegationsBySession.delete(delegation.sessionID) + this.delegations.delete(delegation.id) + } + + // Remove from filesystem + try { + const dir = await this.getDelegationsDir(sessionID) + const filePath = path.join(dir, `${normalizedId}.md`) + await fs.unlink(filePath) + return true + } catch { + return false + } + } + + /** + * Find a delegation by its session ID + */ + findBySession(sessionID: string): DelegationRecord | undefined { + return this.getDelegationBySession(sessionID) + } + + /** + * Handle message events for progress tracking + */ + handleMessageEvent(sessionID: string, messageText?: string): void { + const delegation = this.findBySession(sessionID) + if (!delegation) return + this.markProgress(delegation.id, messageText) + } + + /** + * Get count of pending delegations for a parent session + */ + getPendingCount(parentSessionID: string): number { + const pendingSet = this.pendingByParent.get(parentSessionID) + if (!pendingSet) return 0 + return Array.from(pendingSet).filter((id) => { + const delegation = this.delegations.get(id) + return delegation ? isActiveStatus(delegation.status) : false + }).length + } + + /** + * Get all currently running delegations (in-memory only) + */ + getRunningDelegations(rootSessionID?: string): DelegationRecord[] { + return Array.from(this.delegations.values()).filter((delegation) => { + if (rootSessionID && delegation.rootSessionID !== rootSessionID) return false + return isActiveStatus(delegation.status) + }) + } + + getUnreadCompletedDelegations(rootSessionID: string, limit = 10): DelegationRecord[] { + return Array.from(this.delegations.values()) + .filter((delegation) => delegation.rootSessionID === rootSessionID) + .filter((delegation) => this.hasUnreadCompletion(delegation)) + .sort((a, b) => { + const aTime = a.completedAt?.getTime() || 0 + const bTime = b.completedAt?.getTime() || 0 + return bTime - aTime + }) + .slice(0, limit) + } + + /** + * Get recent completed delegations for compaction injection + */ + async getRecentCompletedDelegations( + sessionID: string, + limit: number = 10, + ): Promise { + const all = await this.listDelegations(sessionID) + return all.filter((d) => isTerminalStatus(d.status)).slice(-limit) + } + + /** + * Log debug messages + */ + async debugLog(msg: string): Promise { + // Only log if debug is enabled (could be env var or static const) + // For now, mirroring previous behavior but writing to the new baseDir/debug.log + const timestamp = new Date().toISOString() + const line = `${timestamp}: ${msg}\n` + const debugFile = path.join(this.baseDir, "background-agents-debug.log") + + try { + await fs.appendFile(debugFile, line, "utf8") + } catch { + // Ignore errors, try to ensure dir once if it fails? + // Simpler to just ignore for debug logs + } + } +} + +// ========================================== +// TOOL CREATORS +// ========================================== + +interface DelegateArgs { + prompt: string + agent: string +} + +function createDelegate(manager: DelegationManager): ReturnType { + return tool({ + description: `Delegate a task to an agent. Returns immediately with a readable ID. + +Use this for: +- Research tasks (will be auto-saved) +- Parallel work that can run in background +- Any task where you want persistent, retrievable output + +On completion, a notification will arrive with the ID and terminal summary. +Use \`delegation_read\` with the ID to retrieve full persisted output (including after compaction).`, + args: { + prompt: tool.schema + .string() + .describe("The full detailed prompt for the agent. Must be in English."), + agent: tool.schema + .string() + .describe( + 'Agent to delegate to. Must be a read-only sub-agent (edit/write/bash denied), such as "researcher" or "explore".', + ), + }, + async execute(args: DelegateArgs, toolCtx: ToolContext): Promise { + if (!toolCtx?.sessionID) { + return "❌ delegate requires sessionID. This is a system error." + } + if (!toolCtx?.messageID) { + return "❌ delegate requires messageID. This is a system error." + } + + try { + const delegation = await manager.delegate({ + parentSessionID: toolCtx.sessionID, + parentMessageID: toolCtx.messageID, + parentAgent: toolCtx.agent, + prompt: args.prompt, + agent: args.agent, + }) + + // Get total active count for this parent session + const pendingSet = manager.getPendingCount(toolCtx.sessionID) + const totalActive = pendingSet + + let response = `Delegation started: ${delegation.id}\nAgent: ${args.agent}` + if (totalActive > 1) { + response += `\n\n${totalActive} delegations now active.` + } + response += `\nYou WILL be notified when ${totalActive > 1 ? "ALL complete" : "complete"}. Do NOT poll.` + + return response + } catch (error) { + // Return validation errors as guidance, not exceptions + return `❌ Delegation failed:\n\n${error instanceof Error ? error.message : "Unknown error"}` + } + }, + }) +} + +function createDelegationRead(manager: DelegationManager): ReturnType { + return tool({ + description: `Read the output of a delegation by its ID. +Use this to retrieve results from delegated tasks if the inline notification was lost during compaction.`, + args: { + id: tool.schema.string().describe("The delegation ID (e.g., 'elegant-blue-tiger')"), + }, + async execute(args: { id: string }, toolCtx: ToolContext): Promise { + if (!toolCtx?.sessionID) { + return "❌ delegation_read requires sessionID. This is a system error." + } + + return await manager.readOutput(toolCtx.sessionID, args.id) + }, + }) +} + +function createDelegationList(manager: DelegationManager): ReturnType { + return tool({ + description: `List all delegations for the current session. +Shows both running and completed delegations.`, + args: {}, + async execute(_args: Record, toolCtx: ToolContext): Promise { + if (!toolCtx?.sessionID) { + return "❌ delegation_list requires sessionID. This is a system error." + } + + const delegations = await manager.listDelegations(toolCtx.sessionID) + + if (delegations.length === 0) { + return "No delegations found for this session." + } + + const lines = delegations.map((d) => { + const titlePart = d.title ? ` | ${d.title}` : "" + const unreadPart = d.unread ? " [unread]" : "" + const descPart = d.description ? `\n β†’ ${d.description}` : "" + return `- **${d.id}**${titlePart} [${d.status}]${unreadPart}${descPart}` + }) + + return `## Delegations\n\n${lines.join("\n")}` + }, + }) +} + +// ========================================== +// DELEGATION RULES (injected into system prompt) +// ========================================== + +const DELEGATION_RULES = ` + + +## Async Delegation + +You have tools for parallel background work: +- \`delegate(prompt, agent)\` - Launch task, returns ID immediately +- \`delegation_read(id)\` - Retrieve completed result +- \`delegation_list()\` - List delegations (use sparingly) + +## Delegation Routing + +Agents route based on their permissions: + +| Agent Type | Tool | Why | +|------------|------|-----| +| Read-only sub-agents (edit/write/bash denied) | \`delegate\` | Background session, async | +| Write-capable sub-agents (any write permission) | \`task\` | Native task, preserves undo/branching | + +**Read-only sub-agents** have edit="deny", write="deny", bash={"*":"deny"}. +**Write-capable sub-agents** have any write tool enabled. + +## How It Works + +1. For read-only sub-agents: Call \`delegate\` with detailed prompt +2. For write-capable sub-agents: Call \`task\` with detailed prompt +3. Continue productive work while it runs +4. Receive notification when complete +5. Call \`delegation_read(id)\` to retrieve results + +## Critical Constraints + +**NEVER poll \`delegation_list\` to check completion.** +You WILL be notified via \`\`. Polling wastes tokens. + +**NEVER wait idle.** Always have productive work while delegations run. + +**Using wrong tool will fail fast with guidance.** + + +` + +// ========================================== +// COMPACTION CONTEXT FORMATTING +// ========================================== + +interface DelegationForContext { + id: string + agent?: string + title?: string + description?: string + status: DelegationStatus + startedAt?: Date + completedAt?: Date + lastHeartbeatAt?: Date + prompt?: string +} + +/** + * Format delegation context for injection during compaction. + * Includes running delegations with notification reminder (only when running exist), + * and recent completed delegations with full descriptions. + */ +function formatDelegationContext( + running: DelegationForContext[], + unreadCompleted: DelegationForContext[], +): string { + const sections: string[] = [""] + + // Running delegations (if any) + if (running.length > 0) { + sections.push("## Running Delegations") + sections.push("") + for (const d of running) { + sections.push(`### \`${d.id}\`${d.agent ? ` (${d.agent})` : ""}`) + if (d.startedAt) { + sections.push(`**Started:** ${d.startedAt.toISOString()}`) + } + if (d.lastHeartbeatAt) { + sections.push(`**Last heartbeat:** ${d.lastHeartbeatAt.toISOString()}`) + } + if (d.prompt) { + const truncatedPrompt = d.prompt.length > 200 ? `${d.prompt.slice(0, 200)}...` : d.prompt + sections.push(`**Prompt:** ${truncatedPrompt}`) + } + sections.push("") + } + + // Only include reminder when there ARE running delegations + sections.push( + "> **Note:** You WILL be notified via `` when delegations complete.", + ) + sections.push("> Do NOT poll `delegation_list` - continue productive work.") + sections.push("") + } + + // Unread completed delegations (recent) + if (unreadCompleted.length > 0) { + sections.push("## Unread Completed Delegations") + sections.push("") + for (const d of unreadCompleted) { + const statusEmoji = + d.status === "complete" + ? "βœ…" + : d.status === "error" + ? "❌" + : d.status === "timeout" + ? "⏱️" + : "🚫" + sections.push(`### ${statusEmoji} \`${d.id}\``) + sections.push(`**Title:** ${d.title || "(no title)"}`) + sections.push(`**Status:** ${d.status}`) + sections.push(`**Description:** ${d.description || "(no description)"}`) + if (d.completedAt) { + sections.push(`**Completed:** ${d.completedAt.toISOString()}`) + } + sections.push(`**Retrieve:** \`delegation_read("${d.id}")\``) + sections.push("") + } + sections.push("> These are unread terminal delegations carried forward through compaction.") + sections.push("") + } + + sections.push("## Retrieval") + sections.push('Use `delegation_read("id")` to access full delegation output.') + sections.push("Do not poll delegation_list for completion; rely on task notifications.") + sections.push("") + + return sections.join("\n") +} + +// ========================================== +// PLUGIN EXPORT +// ========================================== + +/** + * Expected input for experimental.chat.system.transform hook. + */ +interface SystemTransformInput { + agent?: string + sessionID?: string +} + +const BackgroundAgentsPlugin: Plugin = async (ctx) => { + const { client, directory } = ctx + + // Create logger early for all components + const log = createLogger(client as OpencodeClient) + + // Project-level storage directory (shared across sessions) + // Uses git root commit hash for cross-worktree consistency + const projectId = await getProjectId(directory) + const baseDir = path.join(os.homedir(), ".local", "share", "opencode", "delegations", projectId) + + // Ensure base directory exists (for debug logs etc) + await fs.mkdir(baseDir, { recursive: true }) + + const manager = new DelegationManager(client as OpencodeClient, baseDir, log) + + await manager.debugLog("BackgroundAgentsPlugin initialized with delegation system") + + return { + tool: { + delegate: createDelegate(manager), + delegation_read: createDelegationRead(manager), + delegation_list: createDelegationList(manager), + }, + + // Prevent read-only agents from using native task tool (symmetric to delegate enforcement) + "tool.execute.before": async ( + input: { tool: string }, + output: { args?: { subagent_type?: string } }, + ) => { + // Guard: Only intercept task tool + if (input.tool !== "task") return + + // Guard: Require agent name + const agentName = output.args?.subagent_type + if (!agentName) return + + // Parse boundary 1: Check agent mode + const { isSubAgent } = await parseAgentMode(client as OpencodeClient, agentName, log) + + // Guard: Allow non-sub-agents (main/built-in) + if (!isSubAgent) return + + // Parse boundary 2: Check write capability (only for sub-agents) + const { isReadOnly } = await parseAgentWriteCapability( + client as OpencodeClient, + agentName, + log, + ) + + // Guard: Allow write-capable agents + if (!isReadOnly) return + + // Fail fast: Read-only sub-agent via task is invalid + throw new Error( + `❌ Agent '${agentName}' is read-only and should use the delegate tool for async background execution.\n\n` + + `Read-only agents have: edit="deny", write="deny", bash={"*":"deny"}\n` + + `Use delegate for read-only sub-agents.\n` + + `Use task for write-capable sub-agents.`, + ) + }, + + // Inject delegation rules into system prompt + "experimental.chat.system.transform": async (_input: SystemTransformInput, output) => { + output.system.push(DELEGATION_RULES) + }, + + // Compaction hook - inject delegation context for context recovery + "experimental.session.compacting": async ( + input: { sessionID: string }, + output: { context: string[]; prompt?: string }, + ) => { + const rootSessionID = await manager.getRootSessionID(input.sessionID) + + // Running delegations in this root session tree + const running = manager.getRunningDelegations(rootSessionID).map((d) => ({ + id: d.id, + agent: d.agent, + title: d.title, + description: d.description, + status: d.status, + startedAt: d.startedAt, + lastHeartbeatAt: d.progress.lastHeartbeatAt, + prompt: d.prompt, + })) + + // Unread completed delegations to carry forward through compaction + const unreadCompleted = manager.getUnreadCompletedDelegations(rootSessionID, 10).map((d) => ({ + id: d.id, + agent: d.agent, + title: d.title, + description: d.description, + status: d.status, + completedAt: d.completedAt, + })) + + // Early exit if nothing to inject + if (running.length === 0 && unreadCompleted.length === 0) return + + output.context.push(formatDelegationContext(running, unreadCompleted)) + }, + + // Event hook + event: async ({ event }: { event: Event }): Promise => { + if (event.type === "session.status") { + const statusType = event.properties.status?.type + const sessionID = event.properties.sessionID + if (statusType === "idle" && sessionID) { + await manager.handleSessionIdle(sessionID) + } + } + + if (event.type === "session.idle") { + const sessionID = event.properties.sessionID + if (sessionID) { + await manager.handleSessionIdle(sessionID) + } + } + + if (event.type === "message.updated") { + const eventProperties = event.properties as { + info: { sessionID?: string; role?: string } + parts?: Part[] + } + const sessionID = eventProperties.info.sessionID + if (sessionID) { + const messageText = + eventProperties.info.role === "assistant" + ? (eventProperties.parts + ?.filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") ?? undefined) + : undefined + manager.handleMessageEvent(sessionID, messageText) + } + } + }, + } +} + +const BackgroundAgentsPluginWithInternals = Object.assign(BackgroundAgentsPlugin, { + testInternals: { + DelegationManager, + formatDelegationContext, + }, +} as const) + +export default BackgroundAgentsPluginWithInternals diff --git a/.opencode/plugins/kdco-primitives/cmux.ts b/.opencode/plugins/kdco-primitives/cmux.ts new file mode 100644 index 0000000..58f0830 --- /dev/null +++ b/.opencode/plugins/kdco-primitives/cmux.ts @@ -0,0 +1,41 @@ +export type ResolveExecutable = (command: string) => string | null | undefined +export type CmuxEnvironment = Record + +export interface CmuxContext { + workspaceID?: string + surfaceID?: string + socketPath?: string + socketMode?: string +} + +function normalizeCmuxValue(value?: string): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +export function detectCmuxContext(env: CmuxEnvironment = process.env): CmuxContext { + return { + workspaceID: normalizeCmuxValue(env.CMUX_WORKSPACE_ID), + surfaceID: normalizeCmuxValue(env.CMUX_SURFACE_ID), + socketPath: normalizeCmuxValue(env.CMUX_SOCKET_PATH), + socketMode: normalizeCmuxValue(env.CMUX_SOCKET_MODE), + } +} + +export function canUseCmuxWorkflow( + env: CmuxEnvironment = process.env, + resolveExecutable: ResolveExecutable = (command) => Bun.which(command), + cmuxExecutable: string = "cmux", +): boolean { + if (!resolveExecutable(cmuxExecutable)) { + return false + } + + const context = detectCmuxContext(env) + if (context.workspaceID) { + return true + } + + const socketModeAllowsExternalControl = context.socketMode?.toLowerCase() === "allowall" + return Boolean(context.socketPath && socketModeAllowsExternalControl) +} diff --git a/.opencode/plugins/kdco-primitives/get-project-id.ts b/.opencode/plugins/kdco-primitives/get-project-id.ts new file mode 100644 index 0000000..35da0a0 --- /dev/null +++ b/.opencode/plugins/kdco-primitives/get-project-id.ts @@ -0,0 +1,172 @@ +/** + * Project ID generation for kdco registry plugins. + * + * Generates a stable, unique identifier for a project based on its git history. + * Used for cross-worktree consistency in delegation storage, state databases, + * and other plugin data that should be shared across worktrees. + * + * @module kdco-primitives/get-project-id + */ + +import * as crypto from "node:crypto" +import { stat } from "node:fs/promises" +import * as path from "node:path" +import { logWarn } from "./log-warn" +import type { OpencodeClient } from "./types" +import { TimeoutError, withTimeout } from "./with-timeout" + +/** + * Generate a short hash from a path for project ID fallback. + * + * Used when git root commit is unavailable (non-git repos, empty repos). + * Produces a 16-character hex string for reasonable uniqueness. + * + * @param projectRoot - Absolute path to hash + * @returns 16-char hex hash + */ +function hashPath(projectRoot: string): string { + const hash = crypto.createHash("sha256").update(projectRoot).digest("hex") + return hash.slice(0, 16) +} + +/** + * Generate a unique project ID from the project root path. + * + * **Strategy:** + * 1. Uses the first root commit SHA for stability across renames/moves + * 2. Falls back to path hash for non-git repos or empty repos + * 3. Caches result in .git/opencode for performance + * + * **Git Worktree Support:** + * When .git is a file (worktree), resolves the actual .git directory + * and uses the shared cache. This ensures all worktrees share the same + * project ID and associated data. + * + * @param projectRoot - Absolute path to the project root + * @param client - Optional OpenCode client for logging warnings + * @returns 40-char hex SHA (git root) or 16-char hash (fallback) + * @throws {Error} When projectRoot is invalid or .git file has invalid format + * + * @example + * ```ts + * const projectId = await getProjectId("/home/user/my-repo") + * // Returns: "abc123..." (40-char git hash) + * + * const projectId = await getProjectId("/home/user/non-git-folder") + * // Returns: "def456..." (16-char path hash) + * ``` + */ +export async function getProjectId(projectRoot: string, client?: OpencodeClient): Promise { + // Guard: Validate projectRoot (Law 1: Early Exit, Law 4: Fail Fast) + if (!projectRoot || typeof projectRoot !== "string") { + throw new Error("getProjectId: projectRoot is required and must be a string") + } + + const gitPath = path.join(projectRoot, ".git") + + // Check if .git exists and what type it is + const gitStat = await stat(gitPath).catch(() => null) + + // Guard: No .git directory - not a git repo (Law 1: Early Exit) + if (!gitStat) { + logWarn(client, "project-id", `No .git found at ${projectRoot}, using path hash`) + return hashPath(projectRoot) + } + + let gitDir = gitPath + + // Handle worktree case: .git is a file containing gitdir reference + if (gitStat.isFile()) { + const content = await Bun.file(gitPath).text() + const match = content.match(/^gitdir:\s*(.+)$/m) + + // Guard: Invalid .git file format (Law 4: Fail Fast) + if (!match) { + throw new Error(`getProjectId: .git file exists but has invalid format at ${gitPath}`) + } + + // Resolve path (handles both relative and absolute) + const gitdirPath = match[1].trim() + const resolvedGitdir = path.resolve(projectRoot, gitdirPath) + + // The gitdir contains a 'commondir' file pointing to shared .git + const commondirPath = path.join(resolvedGitdir, "commondir") + const commondirFile = Bun.file(commondirPath) + + if (await commondirFile.exists()) { + const commondirContent = (await commondirFile.text()).trim() + gitDir = path.resolve(resolvedGitdir, commondirContent) + } else { + // Fallback to ../.. assumption for older git or unusual setups + gitDir = path.resolve(resolvedGitdir, "../..") + } + + // Guard: Resolved gitdir must be a directory (Law 4: Fail Fast) + const gitDirStat = await stat(gitDir).catch(() => null) + if (!gitDirStat?.isDirectory()) { + throw new Error(`getProjectId: Resolved gitdir ${gitDir} is not a directory`) + } + } + + // Check cache in .git/opencode + const cacheFile = path.join(gitDir, "opencode") + const cache = Bun.file(cacheFile) + + if (await cache.exists()) { + const cached = (await cache.text()).trim() + // Validate cache content (40-char hex for git hash, or 16-char for path hash) + if (/^[a-f0-9]{40}$/i.test(cached) || /^[a-f0-9]{16}$/i.test(cached)) { + return cached + } + logWarn(client, "project-id", `Invalid cache content at ${cacheFile}, regenerating`) + } + + // Generate project ID from git root commit + try { + const proc = Bun.spawn(["git", "rev-list", "--max-parents=0", "--all"], { + cwd: projectRoot, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined }, + }) + + // 5 second timeout to prevent hangs on network filesystems + const timeoutMs = 5000 + const exitCode = await withTimeout(proc.exited, timeoutMs, `git rev-list timed out`).catch( + (e) => { + if (e instanceof TimeoutError) { + proc.kill() + } + return 1 // Treat timeout/errors as failure, fall back to path hash + }, + ) + + if (exitCode === 0) { + const output = await new Response(proc.stdout).text() + const roots = output + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .sort() + + if (roots.length > 0 && /^[a-f0-9]{40}$/i.test(roots[0])) { + const projectId = roots[0] + // Cache the result + try { + await Bun.write(cacheFile, projectId) + } catch (e) { + logWarn(client, "project-id", `Failed to cache project ID: ${e}`) + } + return projectId + } + } else { + const stderr = await new Response(proc.stderr).text() + logWarn(client, "project-id", `git rev-list failed (${exitCode}): ${stderr.trim()}`) + } + } catch (error) { + logWarn(client, "project-id", `git command failed: ${error}`) + } + + // Fallback to path hash + return hashPath(projectRoot) +} diff --git a/.opencode/plugins/kdco-primitives/index.ts b/.opencode/plugins/kdco-primitives/index.ts new file mode 100644 index 0000000..54e4396 --- /dev/null +++ b/.opencode/plugins/kdco-primitives/index.ts @@ -0,0 +1,33 @@ +/** + * Shared primitives for kdco registry plugins. + * + * This module provides common utilities extracted from multiple plugin files + * to eliminate duplication and ensure consistent behavior across plugins. + * + * @module kdco-primitives + */ + +// Project identification +export { getProjectId } from "./get-project-id" + +// Logging +export { logWarn } from "./log-warn" +// Concurrency +export { Mutex } from "./mutex" +// Shell escaping +export { assertShellSafe, escapeAppleScript, escapeBash, escapeBatch } from "./shell" +// Temp directory +export { getTempDir } from "./temp" +// Terminal detection +export { + canUseCmuxWorkflow, + detectCmuxContext, + type CmuxContext, + type CmuxEnvironment, + type ResolveExecutable, +} from "./cmux" +export { isInsideTmux } from "./terminal-detect" +// Types +export type { OpencodeClient } from "./types" +// Timeout handling +export { TimeoutError, withTimeout } from "./with-timeout" diff --git a/.opencode/plugins/kdco-primitives/log-warn.ts b/.opencode/plugins/kdco-primitives/log-warn.ts new file mode 100644 index 0000000..8531e9d --- /dev/null +++ b/.opencode/plugins/kdco-primitives/log-warn.ts @@ -0,0 +1,51 @@ +/** + * Warning logger for kdco registry plugins. + * + * Provides a unified interface for logging warnings that works with + * both the OpenCode client (when available) and console fallback. + * + * @module kdco-primitives/log-warn + */ + +import type { OpencodeClient } from "./types" + +/** + * Log a warning message via OpenCode client or console fallback. + * + * Uses the OpenCode logging API when a client is available, which integrates + * with the OpenCode UI log panel. Falls back to console.warn for CLI contexts + * or when no client is provided. + * + * @param client - Optional OpenCode client for proper logging integration + * @param service - Service name for log categorization (e.g., "worktree", "delegation") + * @param message - Warning message to log + * + * @example + * ```ts + * // With client - logs to OpenCode UI + * logWarn(client, "delegation", "Task timed out after 30s") + * + * // Without client - logs to console + * logWarn(undefined, "delegation", "Task timed out after 30s") + * ``` + */ +export function logWarn( + client: OpencodeClient | undefined, + service: string, + message: string, +): void { + // Guard: No client available, use console fallback (Law 1: Early Exit) + if (!client) { + console.warn(`[${service}] ${message}`) + return + } + + // Happy path: Use OpenCode logging API + client.app + .log({ + body: { service, level: "warn", message }, + }) + .catch(() => { + // Silently ignore logging failures - don't disrupt caller + }) +} diff --git a/.opencode/plugins/kdco-primitives/mutex.ts b/.opencode/plugins/kdco-primitives/mutex.ts new file mode 100644 index 0000000..7fe2a17 --- /dev/null +++ b/.opencode/plugins/kdco-primitives/mutex.ts @@ -0,0 +1,122 @@ +/** + * Promise-based mutex for serializing async operations. + * + * Provides a simple lock mechanism using native Promise mechanics. + * No external dependencies required. + * + * @module kdco-primitives/mutex + */ + +/** + * Simple promise-based mutex for serializing async operations. + * + * Uses a queue of pending waiters to ensure fair ordering. + * Each waiter is resolved in FIFO order when the lock is released. + * + * @example + * ```ts + * const mutex = new Mutex() + * + * // Option 1: Manual acquire/release (use try-finally!) + * await mutex.acquire() + * try { + * await criticalSection() + * } finally { + * mutex.release() + * } + * + * // Option 2: Automatic acquire/release (preferred) + * const result = await mutex.runExclusive(async () => { + * return await criticalSection() + * }) + * ``` + */ +export class Mutex { + private locked = false + private queue: (() => void)[] = [] + + /** + * Acquire the mutex lock. + * + * If the mutex is unlocked, immediately acquires and returns. + * If locked, waits in queue until released by current holder. + * + * @returns Promise that resolves when lock is acquired + * + * @example + * ```ts + * await mutex.acquire() + * try { + * // Critical section - only one caller at a time + * } finally { + * mutex.release() // Always release in finally! + * } + * ``` + */ + async acquire(): Promise { + // Fast path: lock is available (Law 1: Early Exit) + if (!this.locked) { + this.locked = true + return + } + + // Slow path: wait in queue for lock release + return new Promise((resolve) => { + this.queue.push(resolve) + }) + } + + /** + * Release the mutex lock. + * + * If waiters are queued, passes the lock to the next waiter (FIFO). + * Otherwise, marks the mutex as unlocked. + * + * @example + * ```ts + * mutex.release() // Must be called after acquire() + * ``` + */ + release(): void { + const next = this.queue.shift() + if (next) { + // Pass lock to next waiter (stays locked) + next() + } else { + // No waiters, unlock + this.locked = false + } + } + + /** + * Execute a function exclusively under mutex protection. + * + * Automatically acquires the lock before execution and releases + * after completion, even if the function throws an error. + * This is the preferred way to use the mutex. + * + * @param fn - Async function to execute exclusively + * @returns The function's return value + * + * @example + * ```ts + * // Serialize tmux commands to prevent socket races + * const result = await tmuxMutex.runExclusive(async () => { + * return await execTmuxCommand(["list-windows"]) + * }) + * + * // Serialize database writes + * await dbMutex.runExclusive(async () => { + * await db.update(record) + * }) + * ``` + */ + async runExclusive(fn: () => Promise): Promise { + await this.acquire() + try { + return await fn() + } finally { + this.release() + } + } +} diff --git a/.opencode/plugins/kdco-primitives/shell.ts b/.opencode/plugins/kdco-primitives/shell.ts new file mode 100644 index 0000000..73d10b9 --- /dev/null +++ b/.opencode/plugins/kdco-primitives/shell.ts @@ -0,0 +1,138 @@ +/** + * Shell escaping utilities for cross-platform terminal commands. + * + * Provides safe escaping functions for Bash, Windows Batch, and AppleScript. + * All functions validate input for forbidden characters before escaping. + * + * @module kdco-primitives/shell + */ + +/** + * Characters that cannot be safely escaped in any shell. + * Null bytes (\x00) cannot be represented in C strings and must be rejected. + */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: Null byte detection is intentional for security +const SHELL_FORBIDDEN_CHARS = /[\x00]/ + +/** + * Assert that a string is safe for shell escaping. + * + * Null bytes cannot be escaped in any shell and must be rejected outright. + * This is the first line of defense before any escaping is attempted. + * + * @param value - String to validate + * @param context - Description for error message (e.g., "Bash argument") + * @throws {Error} if string contains forbidden characters + * + * @example + * ```ts + * assertShellSafe(userInput, "Bash argument") + * // Throws: "Bash argument contains null bytes..." + * + * assertShellSafe(filePath, "Script path") + * // Throws: "Script path contains null bytes..." + * ``` + */ +export function assertShellSafe(value: string, context: string): void { + // Law 4: Fail Fast - reject invalid input immediately with clear message + if (SHELL_FORBIDDEN_CHARS.test(value)) { + throw new Error( + `${context} contains null bytes which cannot be safely escaped for shell execution`, + ) + } +} + +/** + * Escape a string for safe use in bash double-quoted strings. + * + * Handles all shell metacharacters including: + * - Backslash (\), double quote ("), dollar ($), backtick (`) + * - Exclamation mark (!) for history expansion + * - Newlines and carriage returns (replaced with spaces) + * + * @param str - String to escape + * @returns Escaped string safe for bash double-quoted context + * @throws {Error} if string contains null bytes + * + * @example + * ```ts + * const path = '/home/user/my "project"' + * const cmd = `cd "${escapeBash(path)}"` + * // Result: cd "/home/user/my \"project\"" + * + * const var = '$HOME/file' + * const cmd = `echo "${escapeBash(var)}"` + * // Result: echo "\$HOME/file" + * ``` + */ +export function escapeBash(str: string): string { + assertShellSafe(str, "Bash argument") + return str + .replace(/\\/g, "\\\\") // Backslash first (order matters!) + .replace(/"/g, '\\"') // Double quotes + .replace(/\$/g, "\\$") // Dollar sign (variable expansion) + .replace(/`/g, "\\`") // Backticks (command substitution) + .replace(/!/g, "\\!") // History expansion + .replace(/\n/g, " ") // Newlines -> spaces + .replace(/\r/g, " ") // Carriage returns -> spaces +} + +/** + * Escape a string for safe use in AppleScript double-quoted strings. + * + * AppleScript uses different escaping rules than POSIX shells. + * Only backslash and double quote need escaping. + * Newlines are replaced with spaces (AppleScript doesn't support \n escapes). + * + * @param str - String to escape + * @returns Escaped string safe for AppleScript double-quoted context + * @throws {Error} if string contains null bytes + * + * @example + * ```ts + * const path = '/Users/name/my "project"' + * const script = `tell application "Terminal" to write text "${escapeAppleScript(path)}"` + * // Result: ... write text "/Users/name/my \"project\"" + * ``` + */ +export function escapeAppleScript(str: string): string { + assertShellSafe(str, "AppleScript argument") + return str + .replace(/\\/g, "\\\\") // Backslash + .replace(/"/g, '\\"') // Double quotes + .replace(/\n/g, " ") // Newlines -> spaces + .replace(/\r/g, " ") // Carriage returns -> spaces +} + +/** + * Escape a string for safe use in Windows batch files. + * + * Handles batch metacharacters: + * - Percent (%), caret (^), ampersand (&) + * - Less than (<), greater than (>), pipe (|) + * + * @param str - String to escape + * @returns Escaped string safe for batch file context + * @throws {Error} if string contains null bytes + * + * @example + * ```ts + * const path = 'C:\\Users\\name\\project & files' + * const cmd = `cd /d "${escapeBatch(path)}"` + * // Result: cd /d "C:\Users\name\project ^& files" + * + * const var = '100%' + * const cmd = `echo ${escapeBatch(var)}` + * // Result: echo 100%% + * ``` + */ +export function escapeBatch(str: string): string { + assertShellSafe(str, "Batch argument") + return str + .replace(/%/g, "%%") // Percent (double to escape) + .replace(/\^/g, "^^") // Caret (escape character itself) + .replace(/&/g, "^&") // Ampersand + .replace(//g, "^>") // Greater than + .replace(/\|/g, "^|") // Pipe +} diff --git a/.opencode/plugins/kdco-primitives/temp.ts b/.opencode/plugins/kdco-primitives/temp.ts new file mode 100644 index 0000000..ef9ab69 --- /dev/null +++ b/.opencode/plugins/kdco-primitives/temp.ts @@ -0,0 +1,36 @@ +/** + * Temp directory utilities. + * + * Provides a reliable temp directory path that resolves symlinks, + * which is critical on macOS where os.tmpdir() returns a symlink. + * + * @module kdco-primitives/temp + */ + +import * as fsSync from "node:fs" +import * as os from "node:os" + +/** + * Get the real temp directory path, resolving symlinks. + * + * This is critical for macOS where `os.tmpdir()` returns `/var/folders/...` + * which is actually a symlink to `/private/var/folders/...`. Many tools + * (including Bun's test harness, VS Code, and Eclipse Theia) need the + * resolved real path for proper file watching and path comparisons. + * + * @returns The real (resolved) temp directory path + * + * @example + * ```ts + * const tempDir = getTempDir() + * // macOS: "/private/var/folders/xx/xxxxx/T" (resolved) + * // Linux: "/tmp" (usually not a symlink) + * // Windows: "C:\\Users\\name\\AppData\\Local\\Temp" + * + * // Use for creating temp files + * const tempFile = path.join(getTempDir(), `script-${Date.now()}.sh`) + * ``` + */ +export function getTempDir(): string { + return fsSync.realpathSync.native(os.tmpdir()) +} diff --git a/.opencode/plugins/kdco-primitives/terminal-detect.ts b/.opencode/plugins/kdco-primitives/terminal-detect.ts new file mode 100644 index 0000000..41fd1c3 --- /dev/null +++ b/.opencode/plugins/kdco-primitives/terminal-detect.ts @@ -0,0 +1,34 @@ +/** + * Terminal detection utilities. + * + * Provides functions to detect the current terminal environment, + * particularly useful for choosing terminal-specific behaviors. + * + * @module kdco-primitives/terminal-detect + */ + +/** + * Check if the current process is running inside a tmux session. + * + * Detects tmux by checking the TMUX environment variable, which tmux + * sets to the socket path and session info when spawning child processes. + * + * @returns `true` if running inside tmux, `false` otherwise + * + * @example + * ```ts + * if (isInsideTmux()) { + * // Use tmux-specific commands (new-window, split-pane, etc.) + * await openTmuxWindow({ windowName: "dev", cwd: projectDir }) + * } else { + * // Fall back to platform-specific terminal + * await openPlatformTerminal(projectDir) + * } + * ``` + */ +export function isInsideTmux(): boolean { + // Law 1: Early Exit - simple boolean check, no complex parsing needed + // The TMUX env var contains socket info like "/tmp/tmux-1000/default,12345,0" + // We only care if it's set (truthy), not its contents + return !!process.env.TMUX +} diff --git a/.opencode/plugins/kdco-primitives/types.ts b/.opencode/plugins/kdco-primitives/types.ts new file mode 100644 index 0000000..c0756e1 --- /dev/null +++ b/.opencode/plugins/kdco-primitives/types.ts @@ -0,0 +1,13 @@ +/** + * Shared types for kdco registry plugins. + * + * @module kdco-primitives/types + */ + +import type { createOpencodeClient } from "@opencode-ai/sdk" + +/** + * OpenCode client instance type. + * Derived from the factory function return type for type safety. + */ +export type OpencodeClient = ReturnType diff --git a/.opencode/plugins/kdco-primitives/with-timeout.ts b/.opencode/plugins/kdco-primitives/with-timeout.ts new file mode 100644 index 0000000..597aa8d --- /dev/null +++ b/.opencode/plugins/kdco-primitives/with-timeout.ts @@ -0,0 +1,84 @@ +/** + * Promise timeout utility for kdco registry plugins. + * + * Provides a clean wrapper around Promise.race for timeout handling, + * replacing inline timeout patterns throughout the codebase. + * + * @module kdco-primitives/with-timeout + */ + +/** + * Error thrown when a promise times out. + * Extends Error for proper instanceof checks and stack traces. + */ +export class TimeoutError extends Error { + readonly name = "TimeoutError" as const + readonly timeoutMs: number + + constructor(message: string, timeoutMs: number) { + super(message) + this.timeoutMs = timeoutMs + } +} + +/** + * Wraps a promise with a timeout. + * + * Uses Promise.race to implement timeout semantics. If the wrapped promise + * doesn't resolve within the specified time, throws a TimeoutError. + * + * **Important:** This does NOT abort the underlying promise - it continues + * running in the background. Use AbortController for true cancellation. + * + * @param promise - The promise to wrap + * @param ms - Timeout in milliseconds + * @param message - Optional error message (defaults to "Operation timed out") + * @returns The resolved value from the promise + * @throws {TimeoutError} When the timeout expires before the promise resolves + * + * @example + * ```ts + * // Basic usage + * const result = await withTimeout( + * fetchData(), + * 5000, + * "Data fetch timed out" + * ) + * + * // Handling timeout + * try { + * const result = await withTimeout(slowOperation(), 1000) + * } catch (error) { + * if (error instanceof TimeoutError) { + * console.log(`Timed out after ${error.timeoutMs}ms`) + * } + * } + * ``` + */ +export async function withTimeout( + promise: Promise, + ms: number, + message = "Operation timed out", +): Promise { + // Guard: Invalid timeout value (Law 1: Early Exit, Law 4: Fail Fast) + if (typeof ms !== "number" || ms < 0) { + throw new Error(`withTimeout: timeout must be a non-negative number, got ${ms}`) + } + + // Guard: Zero timeout means immediate rejection + if (ms === 0) { + throw new TimeoutError(message, ms) + } + + // Race between the promise and a timeout + // Clear timer when promise resolves to prevent leaks + let timeoutId: Timer + return Promise.race([ + promise.finally(() => clearTimeout(timeoutId)), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new TimeoutError(message, ms)) + }, ms) + }), + ]) +} diff --git a/.opencode/plugins/notify.ts b/.opencode/plugins/notify.ts new file mode 100644 index 0000000..e32c923 --- /dev/null +++ b/.opencode/plugins/notify.ts @@ -0,0 +1,997 @@ +/** + * notify + * Native OS notifications for OpenCode + * + * Philosophy: "Notify the human when the AI needs them back, not for every micro-event." + * + * Features: + * - Uses cmux native notifications and session status when running inside cmux + * - Auto-detects terminal emulator (Ghostty, Kitty, iTerm, WezTerm, etc.) + * - Suppresses notifications when terminal is focused (like Ghostty does) + * - Click notification to focus terminal + * - Parent session only by default (no spam from sub-tasks) + * + * Uses cmux CLI first (if available), then desktop notification fallback: + * - cmux: `cmux notify --title ... --subtitle ... --body ...` + * - cmux status: `cmux set-status ` / `cmux clear-status ` + * - macOS: alerter (native Notification Center, requires alerter on PATH) + * - Windows: SnoreToast (native toast notifications) + * - Linux: notify-send (native desktop notifications) + */ + +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import type { Plugin } from "@opencode-ai/plugin" +import type { Event } from "@opencode-ai/sdk" +// @ts-expect-error - installed at runtime by OCX +import detectTerminal from "detect-terminal" +// @ts-expect-error - installed at runtime by OCX +import notifier from "node-notifier" +import type { OpencodeClient } from "./kdco-primitives/types" +import { sendDesktopNotificationByPlatform, sendNotificationWithFallback } from "./notify/backend" +import { + canUseCmuxNotification, + clearCmuxStatus, + sendCmuxNotification, + sendCmuxStatus, +} from "./notify/cmux" +import { + buildCmuxSessionStatusTransitionForEvent, + buildCmuxSessionStatusTransitionForQuestionTool, + getCmuxSessionStatusText, + type CmuxSessionStatusTransition, +} from "./notify/status" +import { parseOscTitleContext, writeOscTitleBestEffort } from "./notify/title" + +interface NotifyConfig { + /** Notify for child/sub-session events (default: false) */ + notifyChildSessions: boolean + /** Sound configuration per event type */ + sounds: { + idle: string + error: string + permission: string + question?: string + } + /** Quiet hours configuration */ + quietHours: { + enabled: boolean + start: string // "HH:MM" format + end: string // "HH:MM" format + } + /** Override terminal detection (optional) */ + terminal?: string +} + +interface TerminalInfo { + name: string | null + bundleId: string | null + processName: string | null +} + +const DEFAULT_CONFIG: NotifyConfig = { + notifyChildSessions: false, + sounds: { + idle: "Glass", + error: "Basso", + permission: "Submarine", + }, + quietHours: { + enabled: false, + start: "22:00", + end: "08:00", + }, +} + +// Terminal name to macOS process name mapping (for focus detection) +const TERMINAL_PROCESS_NAMES: Record = { + ghostty: "Ghostty", + kitty: "kitty", + iterm: "iTerm2", + iterm2: "iTerm2", + wezterm: "WezTerm", + alacritty: "Alacritty", + terminal: "Terminal", + apple_terminal: "Terminal", + hyper: "Hyper", + warp: "Warp", + vscode: "Code", + "vscode-insiders": "Code - Insiders", +} + +// ========================================== +// CONFIGURATION +// ========================================== + +async function loadConfig(): Promise { + const configPath = path.join(os.homedir(), ".config", "opencode", "kdco-notify.json") + + try { + const content = await fs.readFile(configPath, "utf8") + const userConfig = JSON.parse(content) as Partial + + // Merge with defaults + return { + ...DEFAULT_CONFIG, + ...userConfig, + sounds: { + ...DEFAULT_CONFIG.sounds, + ...userConfig.sounds, + }, + quietHours: { + ...DEFAULT_CONFIG.quietHours, + ...userConfig.quietHours, + }, + } + } catch { + // Config doesn't exist or is invalid, use defaults + return DEFAULT_CONFIG + } +} + +// ========================================== +// TERMINAL DETECTION (macOS) +// ========================================== + +async function runOsascript(script: string): Promise { + if (process.platform !== "darwin") return null + + try { + const proc = Bun.spawn(["osascript", "-e", script], { + stdout: "pipe", + stderr: "pipe", + }) + const output = await new Response(proc.stdout).text() + return output.trim() + } catch { + return null + } +} + +async function getBundleId(appName: string): Promise { + return runOsascript(`id of application "${appName}"`) +} + +async function getFrontmostApp(): Promise { + return runOsascript( + 'tell application "System Events" to get name of first application process whose frontmost is true', + ) +} + +async function detectTerminalInfo(config: NotifyConfig): Promise { + // Use config override if provided + const terminalName = config.terminal || detectTerminal() || null + + if (!terminalName) { + return { name: null, bundleId: null, processName: null } + } + + // Get process name for focus detection + const processName = TERMINAL_PROCESS_NAMES[terminalName.toLowerCase()] || terminalName + + // Dynamically get bundle ID from macOS (no hardcoding!) + const bundleId = await getBundleId(processName) + + return { + name: terminalName, + bundleId, + processName, + } +} + +async function isTerminalFocused(terminalInfo: TerminalInfo): Promise { + if (!terminalInfo.processName) return false + if (process.platform !== "darwin") return false + + const frontmost = await getFrontmostApp() + if (!frontmost) return false + + // Case-insensitive comparison + return frontmost.toLowerCase() === terminalInfo.processName.toLowerCase() +} + +// ========================================== +// QUIET HOURS CHECK +// ========================================== + +function isQuietHours(config: NotifyConfig): boolean { + if (!config.quietHours.enabled) return false + + const now = new Date() + const currentMinutes = now.getHours() * 60 + now.getMinutes() + + const [startHour, startMin] = config.quietHours.start.split(":").map(Number) + const [endHour, endMin] = config.quietHours.end.split(":").map(Number) + + const startMinutes = startHour * 60 + startMin + const endMinutes = endHour * 60 + endMin + + // Handle overnight quiet hours (e.g., 22:00 - 08:00) + if (startMinutes > endMinutes) { + return currentMinutes >= startMinutes || currentMinutes < endMinutes + } + + return currentMinutes >= startMinutes && currentMinutes < endMinutes +} + +// ========================================== +// PARENT SESSION DETECTION +// ========================================== + +async function isParentSession(client: OpencodeClient, sessionID: string): Promise { + try { + const session = await client.session.get({ path: { id: sessionID } }) + // No parentID means this IS the parent/root session + return !session.data?.parentID + } catch { + // If we can't fetch, assume it's a parent to be safe (notify rather than miss) + return true + } +} + +// ========================================== +// NOTIFICATION SENDER +// ========================================== + +interface NotificationOptions { + title: string + message: string + subtitle?: string + cmuxBody?: string + sound: string + terminalInfo: TerminalInfo +} + +interface NotificationRuntime { + preferCmux: boolean +} + +const QUESTION_DEDUPE_WINDOW_MS = 1500 +const READY_DEDUPE_WINDOW_MS = 1500 +const PERMISSION_DEDUPE_WINDOW_MS = 1500 +const CMUX_SESSION_STATUS_KEY_PREFIX = "opencode.session" +const CMUX_BUSY_ANIMATION_INTERVAL_MS = 80 +const CMUX_BUSY_ANIMATION_FRAMES = ["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"] as const + +type RecentNotifications = Map +type SessionLogicalState = CmuxSessionStatusTransition["logicalState"] +type CmuxSessionLogicalStateBySessionID = Map< + string, + SessionLogicalState +> +type TitleSessionLogicalStateBySessionID = Map +type CmuxSessionStatusWriteIntent = + | { + readonly sessionID: string + readonly kind: "set-status" + readonly text: string + } + | { + readonly sessionID: string + readonly kind: "clear-status" + } + +function isCmuxSessionStatusWriteIntentEqual( + left: CmuxSessionStatusWriteIntent, + right: CmuxSessionStatusWriteIntent, +): boolean { + if (left.kind !== right.kind) return false + if (left.kind === "clear-status" || right.kind === "clear-status") return true + return left.text === right.text +} + +function buildCmuxSessionStatusWriteIntentForLogicalState( + sessionID: string, + logicalState: Exclude, +): CmuxSessionStatusWriteIntent { + if (logicalState === "idle") { + return { + sessionID, + kind: "clear-status", + } + } + + return { + sessionID, + kind: "set-status", + text: getCmuxSessionStatusText(logicalState), + } +} + +function buildCmuxSessionStatusKey(sessionID: string): string { + return `${CMUX_SESSION_STATUS_KEY_PREFIX}.${sessionID}` +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null + + const normalized = value.trim() + if (!normalized) return null + + return normalized +} + +function shouldSendDedupedNotification( + recentNotifications: RecentNotifications, + dedupeKey: string, + windowMs: number, + nowMs = Date.now(), +): boolean { + for (const [key, timestamp] of recentNotifications) { + if (nowMs - timestamp >= windowMs) { + recentNotifications.delete(key) + } + } + + const lastSentAt = recentNotifications.get(dedupeKey) + if (lastSentAt !== undefined && nowMs - lastSentAt < windowMs) { + return false + } + + recentNotifications.set(dedupeKey, nowMs) + return true +} + +function buildQuestionToolDedupeKey(sessionID: unknown, callID: unknown): string | null { + const normalizedSessionID = toNonEmptyString(sessionID) + if (!normalizedSessionID) return null + + const normalizedCallID = toNonEmptyString(callID) + if (!normalizedCallID) return null + + return `question:${normalizedSessionID}:${normalizedCallID}` +} + +function buildQuestionEventDedupeKey(properties: unknown): string | null { + if (!properties || typeof properties !== "object") return null + + const record = properties as Record + const normalizedSessionID = toNonEmptyString(record.sessionID) + if (!normalizedSessionID) return null + + const toolInfo = + record.tool && typeof record.tool === "object" + ? (record.tool as Record) + : undefined + const normalizedCallID = toNonEmptyString(toolInfo?.callID) + if (normalizedCallID) { + return `question:${normalizedSessionID}:${normalizedCallID}` + } + + const normalizedRequestID = toNonEmptyString(record.id) + if (normalizedRequestID) { + return `question:${normalizedSessionID}:request:${normalizedRequestID}` + } + + return null +} + +function buildSessionReadyDedupeKey(sessionID: unknown): string | null { + const normalizedSessionID = toNonEmptyString(sessionID) + if (!normalizedSessionID) return null + + return `session-ready:${normalizedSessionID}` +} + +function buildPermissionEventDedupeKey(properties: unknown): string | null { + if (!properties || typeof properties !== "object") return null + + const record = properties as Record + const normalizedRequestID = toNonEmptyString(record.id) + if (!normalizedRequestID) return null + + return `permission:request:${normalizedRequestID}` +} + +async function sendDesktopNotification(options: NotificationOptions): Promise { + const { title, message, sound, terminalInfo } = options + + // Base notification options + const notifyOptions: Record = { + title, + message, + sound, + } + + await sendDesktopNotificationByPlatform({ + platform: process.platform, + title, + message, + subtitle: options.subtitle, + sound, + senderBundleId: terminalInfo.bundleId, + sendNodeNotifierNotification: () => notifier.notify(notifyOptions), + }) +} + +async function sendNotification( + options: NotificationOptions, + runtime: NotificationRuntime, +): Promise { + await sendNotificationWithFallback({ + preferCmux: runtime.preferCmux, + tryCmuxNotify: () => + sendCmuxNotification({ + title: options.title, + subtitle: options.subtitle, + body: options.cmuxBody ?? options.message, + }), + sendDesktopNotification: () => sendDesktopNotification(options), + }) +} + +// ========================================== +// EVENT HANDLERS +// ========================================== + +async function handleSessionIdle( + client: OpencodeClient, + sessionID: string, + config: NotifyConfig, + terminalInfo: TerminalInfo, + notificationRuntime: NotificationRuntime, +): Promise { + // Check if we should notify for this session + if (!config.notifyChildSessions) { + const isParent = await isParentSession(client, sessionID) + if (!isParent) return + } + + // Check quiet hours + if (isQuietHours(config)) return + + // Check if terminal is focused (suppress notification if user is already looking) + if (await isTerminalFocused(terminalInfo)) return + + // Get session info for context + let sessionTitle = "Task" + try { + const session = await client.session.get({ path: { id: sessionID } }) + if (session.data?.title) { + sessionTitle = session.data.title.slice(0, 50) + } + } catch { + // Use default title + } + + await sendNotification( + { + title: "Ready for review", + message: sessionTitle, + subtitle: sessionTitle, + cmuxBody: "OpenCode task is ready for review", + sound: config.sounds.idle, + terminalInfo, + }, + notificationRuntime, + ) +} + +async function handleSessionError( + client: OpencodeClient, + sessionID: string, + error: string | undefined, + config: NotifyConfig, + terminalInfo: TerminalInfo, + notificationRuntime: NotificationRuntime, +): Promise { + // Check if we should notify for this session + if (!config.notifyChildSessions) { + const isParent = await isParentSession(client, sessionID) + if (!isParent) return + } + + // Check quiet hours + if (isQuietHours(config)) return + + // Check if terminal is focused (suppress notification if user is already looking) + if (await isTerminalFocused(terminalInfo)) return + + const errorMessage = error?.slice(0, 100) || "Something went wrong" + + await sendNotification( + { + title: "Something went wrong", + message: errorMessage, + sound: config.sounds.error, + terminalInfo, + }, + notificationRuntime, + ) +} + +async function handlePermissionUpdated( + config: NotifyConfig, + terminalInfo: TerminalInfo, + notificationRuntime: NotificationRuntime, +): Promise { + // Always notify for permission events - AI is blocked waiting for human + // No parent check needed: permissions always need human attention + + // Check quiet hours + if (isQuietHours(config)) return + + // Check if terminal is focused (suppress notification if user is already looking) + if (await isTerminalFocused(terminalInfo)) return + + await sendNotification( + { + title: "Waiting for you", + message: "OpenCode needs your input", + sound: config.sounds.permission, + terminalInfo, + }, + notificationRuntime, + ) +} + +async function handleQuestionAsked( + config: NotifyConfig, + terminalInfo: TerminalInfo, + notificationRuntime: NotificationRuntime, +): Promise { + // Guard: quiet hours only (no focus check for questions - tmux workflow) + if (isQuietHours(config)) return + + const sound = config.sounds.question ?? config.sounds.permission + + await sendNotification( + { + title: "Question for you", + message: "OpenCode needs your input", + sound, + terminalInfo, + }, + notificationRuntime, + ) +} + +// ========================================== +// PLUGIN EXPORT +// ========================================== + +const NotifyPlugin: Plugin = async (ctx) => { + const { client } = ctx + + // Load config once at startup + const config = await loadConfig() + + // Detect terminal once at startup (cached for performance) + const terminalInfo = await detectTerminalInfo(config) + const notificationRuntime: NotificationRuntime = { + preferCmux: canUseCmuxNotification(), + } + const oscTitleContext = parseOscTitleContext() + const shouldSuppressCmuxSessionStatusWrites = oscTitleContext?.mayWriteOscTitle === true + const recentQuestionNotifications: RecentNotifications = new Map() + const recentReadyNotifications: RecentNotifications = new Map() + const recentPermissionNotifications: RecentNotifications = new Map() + const titleSessionLogicalStates: TitleSessionLogicalStateBySessionID = new Map() + const titleBusySessionIDs = new Set() + let titleBusySpinnerFrameIndex = 0 + let titleBusySpinnerTicker: ReturnType | null = null + let lastWrittenOscTitle: string | null = null + const cmuxSessionLogicalStates: CmuxSessionLogicalStateBySessionID = new Map() + const committedCmuxSessionStatusWrites = new Map() + const pendingCmuxSessionStatusWrites = new Map() + const animatedBusySessionIDs = new Set() + const busyAnimationFrameIndexBySessionID = new Map() + let busyAnimationTicker: ReturnType | null = null + let cmuxStatusUpdatesDisabled = shouldSuppressCmuxSessionStatusWrites + let isCmuxStatusDrainActive = false + let inFlightCmuxSessionStatusWrite: CmuxSessionStatusWriteIntent | null = null + + const writeOscTitleIfNeeded = (title: string): void => { + if (!oscTitleContext?.mayWriteOscTitle) return + if (lastWrittenOscTitle === title) return + + lastWrittenOscTitle = title + writeOscTitleBestEffort(title) + } + + const buildBusySpinnerTitle = (): string => { + const frame = + CMUX_BUSY_ANIMATION_FRAMES[titleBusySpinnerFrameIndex] ?? CMUX_BUSY_ANIMATION_FRAMES[0] + titleBusySpinnerFrameIndex = (titleBusySpinnerFrameIndex + 1) % CMUX_BUSY_ANIMATION_FRAMES.length + return `${frame} ${oscTitleContext?.baseTitle ?? ""}` + } + + const stopTitleBusySpinnerTicker = (): void => { + if (!titleBusySpinnerTicker) return + + clearInterval(titleBusySpinnerTicker) + titleBusySpinnerTicker = null + } + + const writeNextBusyOscTitleFrame = (): void => { + if (titleBusySessionIDs.size === 0) { + return + } + + writeOscTitleIfNeeded(buildBusySpinnerTitle()) + } + + const startTitleBusySpinnerTicker = (): void => { + if (!oscTitleContext?.mayWriteOscTitle) return + if (titleBusySpinnerTicker || titleBusySessionIDs.size === 0) return + + const interval = setInterval(() => { + if (titleBusySessionIDs.size === 0) { + stopTitleBusySpinnerTicker() + return + } + + writeNextBusyOscTitleFrame() + }, CMUX_BUSY_ANIMATION_INTERVAL_MS) + + ;(interval as { unref?: () => void }).unref?.() + titleBusySpinnerTicker = interval + } + + const startBusyOscTitleForSession = (sessionID: string): void => { + const wasBusy = titleBusySessionIDs.has(sessionID) + titleBusySessionIDs.add(sessionID) + + if (!wasBusy && titleBusySessionIDs.size === 1) { + titleBusySpinnerFrameIndex = 0 + writeNextBusyOscTitleFrame() + } + + startTitleBusySpinnerTicker() + } + + const stopBusyOscTitleForSession = (sessionID: string): void => { + titleBusySessionIDs.delete(sessionID) + + if (titleBusySessionIDs.size > 0) { + return + } + + stopTitleBusySpinnerTicker() + titleBusySpinnerFrameIndex = 0 + if (oscTitleContext) { + writeOscTitleIfNeeded(oscTitleContext.baseTitle) + } + } + + const applyOscTitleSessionStatusTransition = ( + transition: CmuxSessionStatusTransition | null, + ): void => { + if (!oscTitleContext?.mayWriteOscTitle || !transition) return + + const previousLogicalState = titleSessionLogicalStates.get(transition.sessionID) + if (previousLogicalState === transition.logicalState) return + + titleSessionLogicalStates.set(transition.sessionID, transition.logicalState) + + if (transition.logicalState === "animated-busy") { + startBusyOscTitleForSession(transition.sessionID) + return + } + + stopBusyOscTitleForSession(transition.sessionID) + } + + const pruneCmuxSessionStateAfterTerminalClear = (sessionID: string): boolean => { + if (cmuxSessionLogicalStates.get(sessionID) !== "idle") return false + if (pendingCmuxSessionStatusWrites.has(sessionID)) return false + if (inFlightCmuxSessionStatusWrite?.sessionID === sessionID) return false + + cmuxSessionLogicalStates.delete(sessionID) + committedCmuxSessionStatusWrites.delete(sessionID) + animatedBusySessionIDs.delete(sessionID) + busyAnimationFrameIndexBySessionID.delete(sessionID) + + if (animatedBusySessionIDs.size === 0) { + stopBusyAnimationTicker() + } + + return true + } + + const stopBusyAnimationTicker = (): void => { + if (!busyAnimationTicker) return + + clearInterval(busyAnimationTicker) + busyAnimationTicker = null + } + + const clearBusyAnimationState = (): void => { + stopBusyAnimationTicker() + animatedBusySessionIDs.clear() + busyAnimationFrameIndexBySessionID.clear() + } + + const getLatestCmuxSessionStatusWriteForSession = ( + sessionID: string, + ): CmuxSessionStatusWriteIntent | undefined => { + const pendingWrite = pendingCmuxSessionStatusWrites.get(sessionID) + if (pendingWrite) { + return pendingWrite + } + + if (inFlightCmuxSessionStatusWrite?.sessionID === sessionID) { + return inFlightCmuxSessionStatusWrite + } + + return committedCmuxSessionStatusWrites.get(sessionID) + } + + const dequeueNextCmuxSessionStatusWrite = (): CmuxSessionStatusWriteIntent | null => { + const next = pendingCmuxSessionStatusWrites.values().next().value + if (!next) return null + + pendingCmuxSessionStatusWrites.delete(next.sessionID) + return next + } + + const runCmuxSessionStatusWrite = async ( + writeIntent: CmuxSessionStatusWriteIntent, + ): Promise => { + const statusKey = buildCmuxSessionStatusKey(writeIntent.sessionID) + if (writeIntent.kind === "clear-status") { + return clearCmuxStatus({ key: statusKey }) + } + + return sendCmuxStatus({ + key: statusKey, + text: writeIntent.text, + }) + } + + const drainCmuxSessionStatusWrites = async (): Promise => { + if (isCmuxStatusDrainActive || cmuxStatusUpdatesDisabled) return + + isCmuxStatusDrainActive = true + + try { + while (!cmuxStatusUpdatesDisabled) { + const nextWriteIntent = dequeueNextCmuxSessionStatusWrite() + if (!nextWriteIntent) return + + inFlightCmuxSessionStatusWrite = nextWriteIntent + const didUpdateStatus = await runCmuxSessionStatusWrite(nextWriteIntent) + inFlightCmuxSessionStatusWrite = null + + if (!didUpdateStatus) { + cmuxStatusUpdatesDisabled = true + pendingCmuxSessionStatusWrites.clear() + clearBusyAnimationState() + return + } + + if ( + nextWriteIntent.kind === "clear-status" && + pruneCmuxSessionStateAfterTerminalClear(nextWriteIntent.sessionID) + ) { + continue + } + + committedCmuxSessionStatusWrites.set(nextWriteIntent.sessionID, nextWriteIntent) + } + } finally { + inFlightCmuxSessionStatusWrite = null + isCmuxStatusDrainActive = false + + if (!cmuxStatusUpdatesDisabled && pendingCmuxSessionStatusWrites.size > 0) { + void drainCmuxSessionStatusWrites() + } + } + } + + const enqueueCmuxSessionStatusWrite = (writeIntent: CmuxSessionStatusWriteIntent): void => { + if (!notificationRuntime.preferCmux || cmuxStatusUpdatesDisabled) return + + const latestWriteIntent = getLatestCmuxSessionStatusWriteForSession(writeIntent.sessionID) + if (latestWriteIntent && isCmuxSessionStatusWriteIntentEqual(latestWriteIntent, writeIntent)) return + + pendingCmuxSessionStatusWrites.set(writeIntent.sessionID, writeIntent) + void drainCmuxSessionStatusWrites() + } + + const enqueueNextBusyAnimationFrame = (sessionID: string): void => { + if (!animatedBusySessionIDs.has(sessionID)) return + + const frameIndex = busyAnimationFrameIndexBySessionID.get(sessionID) ?? 0 + const frameText = CMUX_BUSY_ANIMATION_FRAMES[frameIndex] ?? CMUX_BUSY_ANIMATION_FRAMES[0] + + busyAnimationFrameIndexBySessionID.set( + sessionID, + (frameIndex + 1) % CMUX_BUSY_ANIMATION_FRAMES.length, + ) + + enqueueCmuxSessionStatusWrite({ + sessionID, + kind: "set-status", + text: frameText, + }) + } + + const startBusyAnimationTicker = (): void => { + if (busyAnimationTicker || cmuxStatusUpdatesDisabled || animatedBusySessionIDs.size === 0) return + + const interval = setInterval(() => { + if (cmuxStatusUpdatesDisabled) { + clearBusyAnimationState() + return + } + + if (animatedBusySessionIDs.size === 0) { + stopBusyAnimationTicker() + return + } + + for (const sessionID of animatedBusySessionIDs) { + enqueueNextBusyAnimationFrame(sessionID) + } + }, CMUX_BUSY_ANIMATION_INTERVAL_MS) + + ;(interval as { unref?: () => void }).unref?.() + busyAnimationTicker = interval + } + + const startBusyAnimationForSession = (sessionID: string): void => { + const wasAnimating = animatedBusySessionIDs.has(sessionID) + animatedBusySessionIDs.add(sessionID) + + if (!wasAnimating) { + busyAnimationFrameIndexBySessionID.set(sessionID, 0) + enqueueNextBusyAnimationFrame(sessionID) + } + + startBusyAnimationTicker() + } + + const stopBusyAnimationForSession = (sessionID: string): void => { + animatedBusySessionIDs.delete(sessionID) + busyAnimationFrameIndexBySessionID.delete(sessionID) + + if (animatedBusySessionIDs.size === 0) { + stopBusyAnimationTicker() + } + } + + const applyCmuxSessionStatusTransition = ( + transition: CmuxSessionStatusTransition | null, + ): void => { + if (!notificationRuntime.preferCmux || !transition || cmuxStatusUpdatesDisabled) return + + const previousLogicalState = cmuxSessionLogicalStates.get(transition.sessionID) + if (previousLogicalState === transition.logicalState) return + + cmuxSessionLogicalStates.set(transition.sessionID, transition.logicalState) + + if (transition.logicalState === "animated-busy") { + startBusyAnimationForSession(transition.sessionID) + return + } + + stopBusyAnimationForSession(transition.sessionID) + enqueueCmuxSessionStatusWrite( + buildCmuxSessionStatusWriteIntentForLogicalState( + transition.sessionID, + transition.logicalState, + ), + ) + } + + const applyRuntimeSessionStatusTransition = ( + transition: CmuxSessionStatusTransition | null, + ): void => { + applyOscTitleSessionStatusTransition(transition) + applyCmuxSessionStatusTransition(transition) + } + + const notifyQuestionIfNeeded = async (dedupeKey: string | null): Promise => { + if ( + dedupeKey && + !shouldSendDedupedNotification( + recentQuestionNotifications, + dedupeKey, + QUESTION_DEDUPE_WINDOW_MS, + ) + ) { + return + } + + await handleQuestionAsked(config, terminalInfo, notificationRuntime) + } + + const notifySessionReadyIfNeeded = async (sessionID: unknown): Promise => { + const normalizedSessionID = toNonEmptyString(sessionID) + if (!normalizedSessionID) return + + const dedupeKey = buildSessionReadyDedupeKey(normalizedSessionID) + if (!dedupeKey) return + + if ( + !shouldSendDedupedNotification(recentReadyNotifications, dedupeKey, READY_DEDUPE_WINDOW_MS) + ) { + return + } + + await handleSessionIdle( + client as OpencodeClient, + normalizedSessionID, + config, + terminalInfo, + notificationRuntime, + ) + } + + const notifyPermissionIfNeeded = async (properties: unknown): Promise => { + const dedupeKey = buildPermissionEventDedupeKey(properties) + + if ( + dedupeKey && + !shouldSendDedupedNotification( + recentPermissionNotifications, + dedupeKey, + PERMISSION_DEDUPE_WINDOW_MS, + ) + ) { + return + } + + await handlePermissionUpdated(config, terminalInfo, notificationRuntime) + } + + return { + "tool.execute.before": async (input: { tool: string; sessionID: string; callID: string }) => { + if (input.tool === "question") { + applyRuntimeSessionStatusTransition( + buildCmuxSessionStatusTransitionForQuestionTool(input.sessionID), + ) + await notifyQuestionIfNeeded(buildQuestionToolDedupeKey(input.sessionID, input.callID)) + } + }, + event: async ({ event }: { event: Event }): Promise => { + const runtimeEvent = event as { type: string; properties: Record } + const runtimeSessionStatusTransition = buildCmuxSessionStatusTransitionForEvent( + runtimeEvent.type, + runtimeEvent.properties, + ) + applyRuntimeSessionStatusTransition(runtimeSessionStatusTransition) + + switch (runtimeEvent.type) { + case "session.status": + case "session.idle": { + if (runtimeSessionStatusTransition?.logicalState === "idle") { + await notifySessionReadyIfNeeded(runtimeSessionStatusTransition.sessionID) + } + break + } + case "session.error": { + const sessionID = toNonEmptyString(runtimeEvent.properties.sessionID) + const error = runtimeEvent.properties.error + const errorMessage = typeof error === "string" ? error : error ? String(error) : undefined + if (sessionID) { + await handleSessionError( + client as OpencodeClient, + sessionID, + errorMessage, + config, + terminalInfo, + notificationRuntime, + ) + } + break + } + + case "permission.updated": + case "permission.asked": { + await notifyPermissionIfNeeded(runtimeEvent.properties) + break + } + case "question.asked": { + const dedupeKey = buildQuestionEventDedupeKey(runtimeEvent.properties) + await notifyQuestionIfNeeded(dedupeKey) + break + } + } + }, + } +} + +export default NotifyPlugin diff --git a/.opencode/plugins/notify/backend.ts b/.opencode/plugins/notify/backend.ts new file mode 100644 index 0000000..24d259b --- /dev/null +++ b/.opencode/plugins/notify/backend.ts @@ -0,0 +1,109 @@ +interface NotifyBackendOptions { + preferCmux: boolean + tryCmuxNotify: () => Promise + sendDesktopNotification: () => void | Promise +} + +export interface DesktopNotificationOptions { + title: string + message: string + subtitle?: string + sound?: string + senderBundleId?: string | null +} + +interface DesktopNotificationRouterOptions extends DesktopNotificationOptions { + platform: NodeJS.Platform | string + sendNodeNotifierNotification: () => void + sendMacOSNotification?: (options: DesktopNotificationOptions) => Promise +} + +interface AlerterProcess { + exited: Promise +} + +interface AlerterRuntime { + which?: (command: string) => string | null | Promise + spawnProcess?: (argv: string[]) => AlerterProcess + warn?: (message: string) => void +} + +const ALERTER_INSTALL_HINT = + "install vjeantet/alerter (brew install vjeantet/tap/alerter) and ensure it is on PATH" + +export function buildAlerterArguments(options: DesktopNotificationOptions): string[] { + const argv = ["alerter", "--message", options.message, "--title", options.title] + + if (options.subtitle) { + argv.push("--subtitle", options.subtitle) + } + + if (options.sound) { + argv.push("--sound", options.sound) + } + + if (options.senderBundleId) { + argv.push("--sender", options.senderBundleId) + } + + return argv +} + +export async function sendMacOSAlerterNotification( + options: DesktopNotificationOptions, + runtime: AlerterRuntime = {}, +): Promise { + const which = runtime.which ?? Bun.which + const warn = runtime.warn ?? console.warn + + try { + const alerterPath = await which("alerter") + if (!alerterPath) { + warn(`notify: macOS desktop notification skipped; alerter not found on PATH (${ALERTER_INSTALL_HINT}).`) + return false + } + + const alerterArguments = buildAlerterArguments(options) + const spawnProcess = runtime.spawnProcess ?? ((argv: string[]) => Bun.spawn(argv, { stdout: "ignore", stderr: "pipe" })) + const process = spawnProcess([alerterPath, ...alerterArguments.slice(1)]) + const exitCode = await process.exited + + if (exitCode === 0) return true + + warn(`notify: macOS desktop notification skipped; alerter exited with code ${exitCode}.`) + return false + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + warn(`notify: macOS desktop notification skipped; alerter failed (${message}).`) + return false + } +} + +export async function sendDesktopNotificationByPlatform( + options: DesktopNotificationRouterOptions, +): Promise { + const { platform, sendNodeNotifierNotification, sendMacOSNotification, ...notificationOptions } = options + + if (platform === "darwin") { + await (sendMacOSNotification ?? sendMacOSAlerterNotification)(notificationOptions) + return + } + + sendNodeNotifierNotification() +} + +export async function sendNotificationWithFallback(options: NotifyBackendOptions): Promise { + if (!options.preferCmux) { + await options.sendDesktopNotification() + return + } + + try { + const sentViaCmux = await options.tryCmuxNotify() + if (sentViaCmux) return + } catch { + // Fall through to desktop notification fallback + } + + await options.sendDesktopNotification() +} diff --git a/.opencode/plugins/notify/cmux.ts b/.opencode/plugins/notify/cmux.ts new file mode 100644 index 0000000..839acac --- /dev/null +++ b/.opencode/plugins/notify/cmux.ts @@ -0,0 +1,128 @@ +import { TimeoutError, withTimeout } from "../kdco-primitives/with-timeout" +import { canUseCmuxWorkflow } from "../kdco-primitives/cmux" + +interface CmuxNotificationPayload { + title: string + body: string + subtitle?: string +} + +interface CmuxStatusPayload { + key: string + text: string +} + +interface CmuxClearStatusPayload { + key: string +} + +type ResolveExecutable = (command: string) => string | null | undefined +type EnvironmentVariables = Record +type CmuxProcess = { + exited: Promise + kill?: () => void +} +type SpawnCmuxProcess = (command: string[]) => CmuxProcess + +const resolveWithBunWhich: ResolveExecutable = (command) => Bun.which(command) +const spawnCmuxWithBun: SpawnCmuxProcess = (command) => + Bun.spawn(command, { + stdout: "ignore", + stderr: "ignore", + }) + +export const CMUX_NOTIFY_TIMEOUT_MS = 1500 +export const CMUX_STATUS_TIMEOUT_MS = CMUX_NOTIFY_TIMEOUT_MS + +type CmuxExecutionOptions = { + timeoutMs?: number + spawnProcess?: SpawnCmuxProcess + cmuxCommand?: string +} + +export function canUseCmuxNotification( + env: EnvironmentVariables = process.env, + resolveExecutable: ResolveExecutable = resolveWithBunWhich, + cmuxCommand: string = "cmux", +): boolean { + return canUseCmuxWorkflow(env, resolveExecutable, cmuxCommand) +} + +export function buildCmuxNotifyArgs(payload: CmuxNotificationPayload): string[] { + const args = ["notify", "--title", payload.title] + + const subtitle = payload.subtitle?.trim() + if (subtitle) { + args.push("--subtitle", subtitle) + } + + args.push("--body", payload.body) + + return args +} + +export function buildCmuxStatusArgs(payload: CmuxStatusPayload): string[] { + return ["set-status", payload.key, payload.text] +} + +export function buildCmuxClearStatusArgs(payload: CmuxClearStatusPayload): string[] { + return ["clear-status", payload.key] +} + +async function executeCmuxCommand(commandArgs: string[], options?: CmuxExecutionOptions): Promise { + const timeoutMs = options?.timeoutMs ?? CMUX_NOTIFY_TIMEOUT_MS + const spawnProcess = options?.spawnProcess ?? spawnCmuxWithBun + const cmuxCommand = options?.cmuxCommand ?? "cmux" + + try { + const proc = spawnProcess([cmuxCommand, ...commandArgs]) + + try { + const exitCode = await withTimeout( + proc.exited, + timeoutMs, + `cmux ${commandArgs[0] ?? "command"} timed out`, + ) + return exitCode === 0 + } catch (error) { + if (error instanceof TimeoutError) { + try { + proc.kill?.() + } catch { + // best effort cleanup + } + } + + return false + } + } catch { + return false + } +} + +export async function sendCmuxNotification( + payload: CmuxNotificationPayload, + options?: CmuxExecutionOptions, +): Promise { + return executeCmuxCommand(buildCmuxNotifyArgs(payload), options) +} + +export async function sendCmuxStatus( + payload: CmuxStatusPayload, + options?: CmuxExecutionOptions, +): Promise { + return executeCmuxCommand(buildCmuxStatusArgs(payload), { + ...options, + timeoutMs: options?.timeoutMs ?? CMUX_STATUS_TIMEOUT_MS, + }) +} + +export async function clearCmuxStatus( + payload: CmuxClearStatusPayload, + options?: CmuxExecutionOptions, +): Promise { + return executeCmuxCommand(buildCmuxClearStatusArgs(payload), { + ...options, + timeoutMs: options?.timeoutMs ?? CMUX_STATUS_TIMEOUT_MS, + }) +} diff --git a/.opencode/plugins/notify/status.ts b/.opencode/plugins/notify/status.ts new file mode 100644 index 0000000..57a7663 --- /dev/null +++ b/.opencode/plugins/notify/status.ts @@ -0,0 +1,83 @@ +type CmuxSessionLogicalState = "animated-busy" | "needs-input" | "error" | "idle" + +export type CmuxSessionStatusTransition = { + readonly sessionID: string + readonly logicalState: CmuxSessionLogicalState +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null + + const normalized = value.trim() + if (!normalized) return null + + return normalized +} + +function toStatusType(properties: Record): string | null { + const status = properties.status + if (!status || typeof status !== "object") return null + + const statusType = toNonEmptyString((status as Record).type) + if (!statusType) return null + + return statusType.toLowerCase() +} + +export function buildCmuxSessionStatusTransitionForEvent( + eventType: string, + properties: Record, +): CmuxSessionStatusTransition | null { + const sessionID = toNonEmptyString(properties.sessionID) + if (!sessionID) return null + + if ( + eventType === "question.asked" || + eventType === "permission.asked" || + eventType === "permission.updated" + ) { + return { sessionID, logicalState: "needs-input" } + } + + if (eventType === "session.idle") { + return { sessionID, logicalState: "idle" } + } + + if (eventType === "session.error") { + return { sessionID, logicalState: "error" } + } + + if (eventType !== "session.status") { + return null + } + + const statusType = toStatusType(properties) + if (statusType === "idle") { + return { sessionID, logicalState: "idle" } + } + + if (statusType === "busy" || statusType === "retry" || statusType === "running") { + return { sessionID, logicalState: "animated-busy" } + } + + return null +} + +export function buildCmuxSessionStatusTransitionForQuestionTool( + sessionID: unknown, +): CmuxSessionStatusTransition | null { + const normalizedSessionID = toNonEmptyString(sessionID) + if (!normalizedSessionID) return null + + return { + sessionID: normalizedSessionID, + logicalState: "needs-input", + } +} + +export function getCmuxSessionStatusText( + logicalState: Exclude, +): string { + if (logicalState === "needs-input") return "Needs input" + return "Error" +} diff --git a/.opencode/plugins/notify/title.ts b/.opencode/plugins/notify/title.ts new file mode 100644 index 0000000..1ca1fa4 --- /dev/null +++ b/.opencode/plugins/notify/title.ts @@ -0,0 +1,80 @@ +export interface OscTitleContext { + readonly mayWriteOscTitle: boolean + readonly baseTitle: string +} + +const OCX_TITLE_CONTEXT_ENV_KEY = "OCX_TITLE_CONTEXT" +const OSC_TITLE_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") { + return null + } + + const normalized = value.trim() + if (!normalized) { + return null + } + + return normalized +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function parseOscTitleContext( + env: Record = process.env, +): OscTitleContext | null { + const rawContext = toNonEmptyString(env[OCX_TITLE_CONTEXT_ENV_KEY]) + if (!rawContext) { + return null + } + + let parsedContext: unknown + try { + parsedContext = JSON.parse(rawContext) + } catch { + return null + } + + if (!isRecord(parsedContext)) { + return null + } + + if (typeof parsedContext.mayWriteOscTitle !== "boolean") { + return null + } + + const baseTitle = toNonEmptyString(parsedContext.baseTitle) + if (!baseTitle) { + return null + } + + return { + mayWriteOscTitle: parsedContext.mayWriteOscTitle, + baseTitle, + } +} + +export function sanitizeOscTitleText(title: string): string { + return title.replace(OSC_TITLE_CONTROL_CHARACTERS, " ").trim() +} + +export function writeOscTitleBestEffort( + title: string, + writer: Pick = process.stdout, +): void { + const sanitizedTitle = sanitizeOscTitleText(title) + if (!sanitizedTitle) { + return + } + + queueMicrotask(() => { + try { + writer.write(`\u001B]0;${sanitizedTitle}\u0007`) + } catch { + // Best-effort: title ownership belongs to launcher cleanup semantics. + } + }) +} diff --git a/.opencode/plugins/workspace-plugin.ts b/.opencode/plugins/workspace-plugin.ts new file mode 100644 index 0000000..f03f91a --- /dev/null +++ b/.opencode/plugins/workspace-plugin.ts @@ -0,0 +1,707 @@ +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { type Plugin, tool } from "@opencode-ai/plugin" +import { z } from "zod" +import { getProjectId } from "./kdco-primitives/get-project-id" + +// ========================================== +// PLAN SCHEMA & VALIDATION +// ========================================== + +const PhaseStatus = z.enum(["PENDING", "IN PROGRESS", "COMPLETE", "BLOCKED"]) + +const TaskSchema = z.object({ + id: z.string().regex(/^\d+\.\d+$/, "Task ID must be hierarchical (e.g., '2.1')"), + checked: z.boolean(), + content: z.string().min(1, "Task content cannot be empty"), + isCurrent: z.boolean().optional(), + citation: z + .string() + .regex(/^ref:[a-z]+-[a-z]+-[a-z]+$/, "Citation must be ref:word-word-word format") + .optional(), +}) + +const PhaseSchema = z.object({ + number: z.number().int().positive(), + name: z.string().min(1, "Phase name cannot be empty"), + status: PhaseStatus, + tasks: z.array(TaskSchema).min(1, "Phase must have at least one task"), +}) + +const FrontmatterSchema = z.object({ + status: z.enum(["not-started", "in-progress", "complete", "blocked"]), + phase: z.number().int().positive(), + updated: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD"), +}) + +const PlanSchema = z.object({ + frontmatter: FrontmatterSchema, + goal: z.string().min(10, "Goal must be at least 10 characters"), + context: z + .array( + z.object({ + decision: z.string(), + rationale: z.string(), + source: z.string(), + }), + ) + .optional(), + phases: z.array(PhaseSchema).min(1, "Plan must have at least one phase"), +}) + +/** + * Result type for plan parsing - either valid data or descriptive error. + * Follows Law 2: Parse Don't Validate - boundary parsing returns trusted types. + */ +type ParseResult = + | { ok: true; data: z.infer; warnings: string[] } + | { ok: false; error: string; hint: string } + +/** + * Raw extracted parts from markdown (no validation). + * Used as intermediate type before Zod validation. + */ +interface ExtractedParts { + frontmatter: Record | null + goal: string | null + phases: Array<{ + number: number + name: string + status: string + tasks: Array<{ + id: string + checked: boolean + content: string + isCurrent: boolean + citation?: string + }> + }> +} + +/** + * Extract all parts from markdown without validation (Law 2: Parse Don't Validate). + * Returns raw extracted data - validation happens in parsePlanMarkdown. + * This is a pure extraction function (Law 3: Purity). + */ +function extractMarkdownParts(content: string): ExtractedParts { + // Extract frontmatter (no validation - just extraction) + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/) + let frontmatter: Record | null = null + + if (fmMatch) { + frontmatter = {} + const fmLines = fmMatch[1].split("\n") + for (const line of fmLines) { + const [key, ...valueParts] = line.split(":") + if (key && valueParts.length > 0) { + const value = valueParts.join(":").trim() + frontmatter[key.trim()] = key.trim() === "phase" ? parseInt(value, 10) : value + } + } + } + + // Extract goal (no validation - just extraction) + const goalMatch = content.match(/## Goal\n([^\n#]+)/) + const goal = goalMatch?.[1]?.trim() || null + + // Extract phases (no validation - just extraction) + const phases: ExtractedParts["phases"] = [] + const phaseRegex = + /## Phase (\d+): ([^[]+)\[([^\]]+)\]\n([\s\S]*?)(?=## Phase \d+:|## Notes|## Blockers|$)/g + + let phaseMatch = phaseRegex.exec(content) + while (phaseMatch !== null) { + const phaseNum = parseInt(phaseMatch[1], 10) + const phaseName = phaseMatch[2].trim() + const phaseStatus = phaseMatch[3].trim() + const phaseContent = phaseMatch[4] + + const tasks: ExtractedParts["phases"][0]["tasks"] = [] + const taskRegex = + /- \[([ x])\] (\*\*)?(\d+\.\d+) ([^←\n]+)(← CURRENT)?.*?(`ref:[a-z]+-[a-z]+-[a-z]+`)?/g + + let taskMatch = taskRegex.exec(phaseContent) + while (taskMatch !== null) { + tasks.push({ + id: taskMatch[3], + checked: taskMatch[1] === "x", + content: taskMatch[4].trim().replace(/\*\*/g, ""), + isCurrent: !!taskMatch[5], + citation: taskMatch[6]?.replace(/`/g, ""), + }) + taskMatch = taskRegex.exec(phaseContent) + } + + // Include phase even if no tasks (let Zod validate) + phases.push({ + number: phaseNum, + name: phaseName, + status: phaseStatus, + tasks, + }) + phaseMatch = phaseRegex.exec(content) + } + + return { frontmatter, goal, phases } +} + +/** + * Format Zod validation errors into human-readable messages (Law 4: Fail Loud). + * Shows ALL errors at once with clear paths. + */ +function formatZodErrors(error: z.ZodError): string { + const errorMessages: string[] = [] + + for (const issue of error.issues) { + const path = issue.path.length > 0 ? `[${issue.path.join(".")}]` : "[root]" + + // Provide helpful context based on error type + let message = issue.message + if (issue.code === "invalid_value") { + const values = (issue as { values?: unknown[] }).values + const input = (issue as { input?: unknown }).input + message = `Invalid value "${input}". Expected: ${values?.join(" | ") ?? "valid value"}` + } else if (issue.code === "invalid_type" && (issue as { input?: unknown }).input === null) { + message = "Required field missing" + } + + errorMessages.push(`${path}: ${message}`) + } + + return errorMessages.join("\n") +} + +/** + * Parse and validate markdown plan in a single boundary operation. + * Returns ParseResult: either trusted data or descriptive error with hint. + * + * Follows all 5 Laws: + * - Law 1 (Early Exit): Guard at top for empty content + * - Law 2 (Parse Don't Validate): Extract all β†’ validate once at end + * - Law 3 (Purity): No side effects, same input = same output + * - Law 4 (Fail Loud): Shows ALL validation errors with clear paths + * - Law 5 (Intentional Naming): Self-documenting function names + */ +function parsePlanMarkdown(content: string): ParseResult { + const skillHint = "Load skill('plan-protocol') for the full format spec." + + // Guard: Content must be string (Law 1: Early Exit, Law 2: Parse at boundary) + if (typeof content !== "string") { + return { + ok: false, + error: `Expected markdown string, received ${typeof content}`, + hint: skillHint, + } + } + + // Guard: Empty content (Law 1: Early Exit) + if (!content.trim()) { + return { + ok: false, + error: "Empty content provided", + hint: skillHint, + } + } + + // Extract all parts without validation (Law 2: Parse Don't Validate) + const parts = extractMarkdownParts(content) + + // Build candidate object for validation + const candidate = { + frontmatter: parts.frontmatter, + goal: parts.goal, + phases: parts.phases, + } + + // Single validation point: Zod schema (Law 2: Parse Don't Validate) + const result = PlanSchema.safeParse(candidate) + if (!result.success) { + return { + ok: false, + error: formatZodErrors(result.error), + hint: skillHint, + } + } + + // Business rules validation (still part of single boundary) + const warnings: string[] = [] + let currentCount = 0 + let inProgressCount = 0 + + for (const phase of result.data.phases) { + if (phase.status === "IN PROGRESS") inProgressCount++ + for (const task of phase.tasks) { + if (task.isCurrent) currentCount++ + } + } + + if (currentCount > 1) { + return { + ok: false, + error: `Multiple tasks marked ← CURRENT (found ${currentCount}). Only one task may be current.`, + hint: skillHint, + } + } + + if (inProgressCount > 1) { + warnings.push("Multiple phases marked IN PROGRESS. Consider focusing on one phase at a time.") + } + + return { ok: true, data: result.data, warnings } +} + +/** + * Format parse error with actionable guidance (Law 4: Fail Loud). + * Includes error message, example, and skill hint. + */ +function formatParseError(error: string, hint: string): string { + return `❌ Plan validation failed: + +${error} + +πŸ’‘ ${hint}` +} + +/** + * Type guard for Node.js filesystem errors (ENOENT, EACCES, etc.) + * Follows "Parse, Don't Validate" - handle uncertainty at boundaries. + */ +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error +} + +/** + * Expected input for experimental.chat.system.transform hook. + * Note: The official SDK types this as {}, but runtime provides these properties. + * See: https://github.com/sst/opencode/issues/6142 + */ +interface SystemTransformInput { + agent?: string + sessionID?: string +} + +/** + * KDCO Workspace Plugin + * + * Provides plan management and targeted rule injection. + * Research functionality has been moved to the delegation system (background-agents). + * Follows "Elegant Defense" philosophy: Flat, Safe, and Fast. + */ + +// ========================================== +// CODER TASK TRACKING FOR REVIEW TRIGGER +// ========================================== + +/** Tracks in-flight coder task callIDs with timestamps for stale cleanup */ +const activeCoderCalls = new Map() + +/** Stale call timeout - matches MAX_RUN_TIME_MS in background-agents.ts */ +const STALE_CALL_TIMEOUT_MS = 15 * 60 * 1000 + +/** Periodic cleanup of orphaned callIDs (runs every 60s) */ +const cleanupInterval = setInterval(() => { + const now = Date.now() + for (const [callID, data] of activeCoderCalls) { + if (now - data.startTime > STALE_CALL_TIMEOUT_MS) { + activeCoderCalls.delete(callID) + } + } +}, 60_000) +// Prevent interval from keeping process alive +cleanupInterval.unref?.() + +// ========================================== +// RULES FOR INJECTION +// ========================================== + +const PLAN_RULES = ` + + +## Agent Routing (STRICT BOUNDARIES) + +| Agent | Scope | Use For | +|-------|-------|---------| +| \`explore\` | **INTERNAL ONLY** - codebase files | Find files, understand code structure, trace logic | +| \`researcher\` | **EXTERNAL ONLY** - outside codebase | Documentation, websites, npm packages, APIs, tutorials | +| \`scribe\` | Human-facing content | Documentation drafts, commit messages, PR descriptions | + +## Critical Constraints + +**You are a READ-ONLY orchestrator. You coordinate research, you do NOT search yourself.** + +- \`explore\` CANNOT access external resources (docs, web, APIs) +- \`researcher\` CANNOT search codebase files +- For external docs about a library used in the codebase β†’ \`researcher\` +- For how that library is used in THIS codebase β†’ \`explore\` + + +User: "What does the OpenAI API say about function calling?" +Correct: delegate to researcher (EXTERNAL - API documentation) +Wrong: Try to answer from memory or use MCP tools directly + + + +User: "Where is the auth middleware in this project?" +Correct: delegate to explore (INTERNAL - codebase search) +Wrong: Use grep/glob directly + + + +User: "How should I implement OAuth2 in this project?" +Correct: + 1. delegate to researcher for OAuth2 best practices (EXTERNAL) + 2. delegate to explore for existing auth patterns (INTERNAL) +Wrong: Search codebase yourself or answer from memory + + + + + +Load relevant skills before finalizing plan: +- Planning work β†’ \`skill\` load \`plan-protocol\` (REQUIRED before using plan_save) +- Backend/logic work β†’ \`skill\` load \`code-philosophy\` +- UI/frontend work β†’ \`skill\` load \`frontend-philosophy\` + + + +Use \`plan_save\` to save your implementation plan as markdown. + +### Format +\`\`\`markdown +--- +status: in-progress +phase: 2 +updated: YYYY-MM-DD +--- + +# Implementation Plan + +## Goal +[One sentence describing the outcome] + +## Context & Decisions +| Decision | Rationale | Source | +|----------|-----------|--------| +| [choice] | [why] | \`ref:delegation-id\` | + +## Phase 1: [Name] [COMPLETE] +- [x] 1.1 Task description +- [x] 1.2 Another task β†’ \`ref:delegation-id\` + +## Phase 2: [Name] [IN PROGRESS] +- [x] 2.1 Completed task +- [ ] **2.2 Current task** ← CURRENT +- [ ] 2.3 Pending task +\`\`\` + +### Rules +1. **One CURRENT task** - Only one task may have ← CURRENT +2. **Cite decisions** - Use \`ref:delegation-id\` for research-informed choices +3. **Update immediately** - Mark tasks complete right after finishing +4. **Auto-save after approval** - When user approves your plan, immediately call \`plan_save\`. Do NOT wait for user to remind you or switch modes. + + + + +## Plan Mode Active +You are in PLAN MODE. Your primary deliverable is a saved implementation plan. + +## Requirements +1. **First**: Load the \`plan-protocol\` skill to understand the required plan schema +2. **During**: Collaborate with the user to develop a comprehensive, well-cited plan +3. **Before exiting**: You MUST call \`plan_save\` with the finalized plan + +## CRITICAL +Saving your plan is a REQUIREMENT, not a request. Plans that are not saved will be lost when the session ends or mode changes. The user cannot see your plan unless you save it. + + +` + +const BUILD_RULES = ` + + +## You Are an ORCHESTRATOR + +You coordinate work. You do NOT implement. + +**CRITICAL CONSTRAINTS:** +- ALL code changes β†’ delegate to \`coder\` +- ALL documentation β†’ delegate to \`scribe\` +- Codebase questions β†’ delegate to \`explore\` (INTERNAL only) +- External docs/APIs β†’ delegate to \`researcher\` (EXTERNAL only) + +**You may directly:** +- Read files for quick context + +**You may NOT:** +- Edit or write any files +- Run bash commands (delegate verification to \`coder\`) + +## Verification Workflow +For any command execution (bun check, bun test, git operations): +1. Delegate to \`coder\` with specific instructions +2. Coder runs commands and reports results +3. You interpret results and decide next actions + +\`coder\` is your execution proxy for ALL bash operations. + + + + + +## Agent Routing (STRICT BOUNDARIES) + +| Agent | Scope | Use For | +|-------|-------|---------| +| \`explore\` | **INTERNAL ONLY** - codebase files | Find files, understand code structure, trace logic | +| \`researcher\` | **EXTERNAL ONLY** - outside codebase | Documentation, websites, npm packages, APIs, tutorials | +| \`coder\` | Implementation | Write/edit code, run builds and tests | +| \`scribe\` | Human-facing content | Documentation, commit messages, PR descriptions | + +## Boundary Rules + +- \`explore\` CANNOT access external resources (docs, web, APIs) +- \`researcher\` CANNOT search codebase files +- \`coder\` handles ALL code modifications +- \`scribe\` handles ALL human-facing content + + + + + +### Before Writing Code +1. Call \`plan_read\` to get the current plan +2. Call \`delegation_list\` ONCE to see available research +3. Call \`delegation_read\` for relevant findings +4. **REUSE code snippets from researcher research** - they are production-ready + +### Philosophy Loading +Load the relevant skill BEFORE delegating to coder: +- Frontend work β†’ \`skill\` load \`frontend-philosophy\` +- Backend work β†’ \`skill\` load \`code-philosophy\` + +### Execution +1. Orient: Read plan with \`plan_read\` and check delegation findings +2. Load: Load relevant philosophy skill(s) +3. Delegate: Send implementation tasks to \`coder\` +4. Verify: Check coder's results, run \`bun check\` if needed +5. Document: Delegate doc updates to \`scribe\` +6. Update: Mark tasks complete in plan + + + + + +## Code Review Protocol + +When implementation is complete (all plan steps done OR user's request fulfilled): +1. BEFORE reporting completion to the user +2. Delegate to \`reviewer\` agent with the list of changed files +3. Include review findings in your completion report +4. If critical (πŸ”΄) or major (🟠) issues found, offer to fix them + +Do NOT skip this step. Do NOT ask permission to review. +The user expects reviewed code, not just implemented code. + +Review triggers: +- All plan tasks marked complete +- User's implementation request fulfilled +- Before saying "done" or "complete" + + +` + +const WorkspacePlugin: Plugin = async (ctx) => { + const { directory } = ctx + + // Use git root commit hash for cross-worktree consistency + const projectId = await getProjectId(directory) + const baseDir = path.join(os.homedir(), ".local", "share", "opencode", "workspace", projectId) + + /** + * Resolves the root session ID by walking up the parent chain. + */ + async function getRootSessionID(sessionID?: string): Promise { + if (!sessionID) { + throw new Error("sessionID is required to resolve root session scope") + } + + let currentID = sessionID + for (let depth = 0; depth < 10; depth++) { + const session = await ctx.client.session.get({ + path: { id: currentID }, + }) + + if (!session.data?.parentID) { + return currentID + } + + currentID = session.data.parentID + } + + throw new Error("Failed to resolve root session: maximum traversal depth exceeded") + } + + return { + tool: { + plan_save: tool({ + description: + "Save the implementation plan as markdown. Must include citations (ref:delegation-id) for decisions based on research. Plan is validated before saving.", + args: { + content: tool.schema.string().describe("The full plan in markdown format"), + }, + async execute(args, toolCtx) { + // Guard 1: Session required (Law 1: Early Exit) + if (!toolCtx?.sessionID) { + return "❌ plan_save requires sessionID. This is a system error." + } + + const rootID = await getRootSessionID(toolCtx.sessionID) + const sessionDir = path.join(baseDir, rootID) + await fs.mkdir(sessionDir, { recursive: true }) + + // Guard 2: Parse and validate at boundary (Law 2: Parse Don't Validate) + const result = parsePlanMarkdown(args.content) + if (!result.ok) { + return formatParseError(result.error, result.hint) + } + + // Happy path: save + await fs.writeFile(path.join(sessionDir, "plan.md"), args.content, "utf8") + const warningCount = result.warnings?.length ?? 0 + const warningText = + warningCount > 0 ? ` (${warningCount} warnings: ${result.warnings?.join(", ")})` : "" + + return `Plan saved.${warningText}` + }, + }), + + plan_read: tool({ + description: "Read the current implementation plan for this session.", + args: { + reason: tool.schema + .string() + .describe("Brief explanation of why you are calling this tool"), + }, + async execute(_args, toolCtx) { + // Guard: Session required (Law 1: Early Exit) + if (!toolCtx?.sessionID) { + return "❌ plan_read requires sessionID. This is a system error." + } + const rootID = await getRootSessionID(toolCtx.sessionID) + const planPath = path.join(baseDir, rootID, "plan.md") + try { + return await fs.readFile(planPath, "utf8") + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return "No plan found." + throw error + } + }, + }), + }, + + // Targeted Rule Injection + "experimental.chat.system.transform": async (input: SystemTransformInput, output) => { + const agent = input.agent + + // Universal date awareness (all agents) - Law 2: Parse intent, not just data + const today = new Date().toISOString().split("T")[0] + output.system.push(` +Today is ${today}. When searching for documentation, APIs, or external resources, use the current year (${new Date().getFullYear()}). Do not default to outdated years from training data. +`) + + // Agent-specific rules + if (agent === "plan") { + output.system.push(PLAN_RULES) + } else if (agent === "build") { + output.system.push(BUILD_RULES) + } + }, + + // Track coder task starts for review trigger + "tool.execute.before": async ( + input: { tool: string; callID?: string }, + output: { args?: { subagent_type?: string } }, + ) => { + if (input.tool !== "task") return + if (!input.callID) return + if (output.args?.subagent_type !== "coder") return + + activeCoderCalls.set(input.callID, { startTime: Date.now() }) + }, + + // Trigger review reminder when plan_save or all coder tasks complete + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown }, + ) => { + // Plan save triggers reviewer delegation reminder + if (input.tool === "plan_save") { + output.output += `\n\n +Plan saved successfully. You MUST now delegate to the reviewer: +1. Use the \`delegate\` tool to send the plan to the \`reviewer\` agent +2. The reviewer will load \`plan-review\` and \`code-philosophy\` skills +3. Use \`plan_read\` to get the plan content for the delegation prompt +4. This is NON-BLOCKING - continue work while review runs in background +` + return + } + + // Coder task completion tracking + if (!input.callID) return + if (!activeCoderCalls.has(input.callID)) return + + activeCoderCalls.delete(input.callID) + + if (activeCoderCalls.size === 0) { + output.output += `\n\n +Coder task complete. Proceed to code review: +1. Delegate to \`reviewer\` agent with the changed files +2. Include findings in your completion report +3. Offer to fix any critical/major issues found +` + } + }, + + // Compaction Hook - Inject plan context when session is compacted + "experimental.session.compacting": async ( + input: { sessionID: string }, + output: { context: string[]; prompt?: string }, + ) => { + const rootID = await getRootSessionID(input.sessionID) + const planPath = path.join(baseDir, rootID, "plan.md") + + let planContent: string | null = null + try { + planContent = await fs.readFile(planPath, "utf8") + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") throw error + } + + if (!planContent) return + + // Extract current task from plan + const currentMatch = planContent.match(/← CURRENT/) + let currentTask: string | null = null + if (currentMatch?.index !== undefined) { + const start = Math.max(0, currentMatch.index - 100) + const end = currentMatch.index + 50 + currentTask = planContent.slice(start, end).match(/\d+\.\d+ [^\n←]+/)?.[0] ?? null + } + + output.context.push(` +## Current Plan +${planContent} + +## Resume Point +${currentTask ? `Current task: ${currentTask}` : "No task marked as CURRENT"} + +## Verification +To verify any cited decision, use \`delegation_read("ref:id")\`. +`) + }, + } +} + +export default WorkspacePlugin diff --git a/.opencode/plugins/worktree.ts b/.opencode/plugins/worktree.ts new file mode 100644 index 0000000..ee178b8 --- /dev/null +++ b/.opencode/plugins/worktree.ts @@ -0,0 +1,1116 @@ +/** + * OCX Worktree Plugin + * + * Creates isolated git worktrees for AI development sessions with + * seamless terminal spawning across macOS, Windows, and Linux. + * + * Inspired by opencode-worktree-session by Felix Anhalt + * https://github.com/felixAnhalt/opencode-worktree-session + * License: MIT + * + * Rewritten for OCX with production-proven patterns. + */ + +import type { Database } from "bun:sqlite" +import { constants as fsConstants } from "node:fs" +import { access, copyFile, cp, mkdir, rm, stat, symlink } from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { type Plugin, tool } from "@opencode-ai/plugin" +import type { Event } from "@opencode-ai/sdk" +import type { OpencodeClient } from "./kdco-primitives/types" + +/** Logger interface for structured logging */ +interface Logger { + debug: (msg: string) => void + info: (msg: string) => void + warn: (msg: string) => void + error: (msg: string) => void +} + +import { parse as parseJsonc } from "jsonc-parser" +import { z } from "zod" + +import { getProjectId } from "./kdco-primitives/get-project-id" +import { + type ActiveLaunchContext, + buildSessionLaunchArgv, + parseActiveLaunchContext, + serializePersistedLaunchMetadata, + toPersistedLaunchMetadata, +} from "./worktree/launch-context" +import { + addSession, + clearPendingDelete, + getPendingDelete, + getSession, + getWorktreePath, + initStateDb, + removeSession, + setPendingDelete, +} from "./worktree/state" +import { openTerminal, type TerminalResult } from "./worktree/terminal" + +/** Maximum retries for database initialization */ +const DB_MAX_RETRIES = 3 + +/** Delay between retry attempts in milliseconds */ +const DB_RETRY_DELAY_MS = 100 + +/** Maximum depth to traverse session parent chain */ +const MAX_SESSION_CHAIN_DEPTH = 10 + +// ============================================================================= +// TYPES & SCHEMAS +// ============================================================================= + +/** Result type for fallible operations */ +interface OkResult { + readonly ok: true + readonly value: T +} +interface ErrResult { + readonly ok: false + readonly error: E +} +type Result = OkResult | ErrResult + +const Result = { + ok: (value: T): OkResult => ({ ok: true, value }), + err: (error: E): ErrResult => ({ ok: false, error }), +} + +/** + * Git branch name validation - blocks invalid refs and shell metacharacters + * Characters blocked: control chars (0x00-0x1f, 0x7f), ~^:?*[]\\, and shell metacharacters + */ +function isValidBranchName(name: string): boolean { + // Check for control characters + for (let i = 0; i < name.length; i++) { + const code = name.charCodeAt(i) + if (code <= 0x1f || code === 0x7f) return false + } + // Check for invalid git ref characters and shell metacharacters + if (/[~^:?*[\]\\;&|`$()]/.test(name)) return false + return true +} + +const branchNameSchema = z + .string() + .min(1, "Branch name cannot be empty") + .refine((name) => !name.startsWith("-"), { + message: "Branch name cannot start with '-' (prevents option injection)", + }) + .refine((name) => !name.startsWith("/") && !name.endsWith("/"), { + message: "Branch name cannot start or end with '/'", + }) + .refine((name) => !name.includes("//"), { + message: "Branch name cannot contain '//'", + }) + .refine((name) => !name.includes("@{"), { + message: "Branch name cannot contain '@{' (git reflog syntax)", + }) + .refine((name) => !name.includes(".."), { + message: "Branch name cannot contain '..'", + }) + // biome-ignore lint/suspicious/noControlCharactersInRegex: Control character detection is intentional for security + .refine((name) => !/[\x00-\x1f\x7f ~^:?*[\]\\]/.test(name), { + message: "Branch name contains invalid characters", + }) + .max(255, "Branch name too long") + .refine((name) => isValidBranchName(name), "Contains invalid git ref characters") + .refine((name) => !name.startsWith(".") && !name.endsWith("."), "Cannot start or end with dot") + .refine((name) => !name.endsWith(".lock"), "Cannot end with .lock") + +/** + * Worktree plugin configuration schema. + * Config file: .opencode/worktree.jsonc + */ +const worktreeConfigSchema = z.object({ + /** Custom base path for worktree storage. Supports ~ for home directory. */ + worktreePath: z.string().optional(), + sync: z + .object({ + /** Files to copy from main worktree (relative paths only) */ + copyFiles: z.array(z.string()).default([]), + /** Directories to symlink from main worktree (saves disk space) */ + symlinkDirs: z.array(z.string()).default([]), + /** Patterns to exclude from copying (reserved for future use) */ + exclude: z.array(z.string()).default([]), + }) + .default(() => ({ copyFiles: [], symlinkDirs: [], exclude: [] })), + hooks: z + .object({ + /** Commands to run after worktree creation */ + postCreate: z.array(z.string()).default([]), + /** Commands to run before worktree deletion */ + preDelete: z.array(z.string()).default([]), + }) + .default(() => ({ postCreate: [], preDelete: [] })), +}) + +type WorktreeConfig = z.infer + +// ============================================================================= +// ERROR TYPES +// ============================================================================= + +class WorktreeError extends Error { + constructor( + message: string, + public readonly operation: string, + public readonly cause?: unknown, + ) { + super(`${operation}: ${message}`) + this.name = "WorktreeError" + } +} + +type ResolveExecutable = (command: string) => string | null | undefined +type ValidateProfileAvailability = ( + ocxBin: string, + profile: string, +) => Promise> + +interface LaunchExecutableValidationOptions { + resolveExecutable?: ResolveExecutable + pathExists?: (absolutePath: string) => Promise +} + +function isPathLikeCommand(command: string): boolean { + return command.includes("/") || command.includes("\\") +} + +function resolveStableLaunchBinaryPath( + ocxBin: string, + baseDirectory: string, + resolveExecutable: ResolveExecutable, +): Result { + if (isPathLikeCommand(ocxBin)) { + const resolvedPath = path.isAbsolute(ocxBin) ? ocxBin : path.resolve(baseDirectory, ocxBin) + return Result.ok(resolvedPath) + } + + const resolvedFromPath = resolveExecutable(ocxBin) + if (!resolvedFromPath) { + return Result.err(`Configured OCX binary "${ocxBin}" is not available in PATH.`) + } + + const resolvedPath = path.isAbsolute(resolvedFromPath) + ? resolvedFromPath + : path.resolve(baseDirectory, resolvedFromPath) + + return Result.ok(resolvedPath) +} + +async function pathPointsToLaunchableBinary(absolutePath: string): Promise { + try { + const stats = await stat(absolutePath) + if (stats.isDirectory()) { + return false + } + + await access(absolutePath, fsConstants.X_OK) + return true + } catch { + return false + } +} + +async function ensureLaunchContextExecutable( + launchContext: ActiveLaunchContext, + baseDirectory: string, + options: LaunchExecutableValidationOptions = {}, +): Promise { + if (launchContext.mode === "plain") { + return launchContext + } + + const { ocxBin, profile } = launchContext + const resolveExecutable = options.resolveExecutable ?? ((command: string) => Bun.which(command)) + const pathExists = options.pathExists ?? pathPointsToLaunchableBinary + const resolvedPathResult = resolveStableLaunchBinaryPath(ocxBin, baseDirectory, resolveExecutable) + if (!resolvedPathResult.ok) { + throw new WorktreeError( + `${resolvedPathResult.error} Repair the parent OCX profile (${profile}) and recreate this worktree session.`, + "launch", + ) + } + + const resolvedPath = resolvedPathResult.value + const isLaunchable = await pathExists(resolvedPath) + if (!isLaunchable) { + throw new WorktreeError( + `Configured OCX binary "${ocxBin}" resolved to "${resolvedPath}" but is missing or stale. Repair the parent OCX profile (${profile}) and recreate this worktree session.`, + "launch", + ) + } + + return { + mode: "ocx", + ocxBin: resolvedPath, + profile, + } +} + +async function validateOcxProfileAvailability( + ocxBin: string, + profile: string, +): Promise> { + try { + const proc = Bun.spawn([ocxBin, "profile", "show", profile, "--global", "--json"], { + stdout: "pipe", + stderr: "pipe", + }) + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + if (exitCode === 0) { + return Result.ok(undefined) + } + + const detail = stderr.trim() || stdout.trim() || `exit ${exitCode}` + return Result.err(detail) + } catch (error) { + return Result.err(error instanceof Error ? error.message : String(error)) + } +} + +async function ensureLaunchContextProfile( + launchContext: ActiveLaunchContext, + validateProfileAvailability: ValidateProfileAvailability = validateOcxProfileAvailability, +): Promise { + if (launchContext.mode === "plain") { + return + } + + const validationResult = await validateProfileAvailability( + launchContext.ocxBin, + launchContext.profile, + ) + if (validationResult.ok) { + return + } + + throw new WorktreeError( + `Configured OCX profile "${launchContext.profile}" is missing or stale. ${validationResult.error} Repair the parent OCX profile and recreate this worktree session.`, + "launch", + ) +} + +// ============================================================================= +// SESSION FORKING HELPERS +// ============================================================================= + +/** + * Check if a path exists, distinguishing ENOENT from other errors (Law 4) + */ +async function pathExists(filePath: string): Promise { + try { + await access(filePath) + return true + } catch (e: unknown) { + if (e && typeof e === "object" && "code" in e && e.code === "ENOENT") { + return false + } + throw e // Re-throw permission errors, etc. + } +} + +/** + * Copy file if source exists. Returns true if copied, false if source doesn't exist. + * Throws on copy failure (Law 4: Fail Loud) + */ +async function copyIfExists(src: string, dest: string): Promise { + if (!(await pathExists(src))) return false + await copyFile(src, dest) + return true +} + +/** + * Copy directory contents if source exists. + * @param src - Source directory path + * @param dest - Destination directory path + * @returns true if copy was performed, false if source doesn't exist + */ +async function copyDirIfExists(src: string, dest: string): Promise { + if (!(await pathExists(src))) return false + await cp(src, dest, { recursive: true }) + return true +} + +interface ForkResult { + forkedSession: { id: string } + rootSessionId: string + planCopied: boolean + delegationsCopied: boolean +} + +interface FinalizeWorktreeLaunchOptions { + database: Database + worktreePath: string + launchArgv: string[] + branch: string + forkedSessionId: string + sessionRecord: { + id: string + branch: string + path: string + createdAt: string + launchMode: "plain" | "ocx" + profile: string | null + ocxBin: string | null + } + log: Logger + openTerminalFn?: (cwd: string, argv?: string[], windowName?: string) => Promise + addSessionFn?: typeof addSession + deleteForkedSessionFn?: (sessionId: string) => Promise +} + +async function finalizeWorktreeLaunch( + options: FinalizeWorktreeLaunchOptions, +): Promise { + const openTerminalFn = options.openTerminalFn ?? openTerminal + const addSessionFn = options.addSessionFn ?? addSession + const deleteForkedSessionFn = + options.deleteForkedSessionFn ?? + (async (_sessionId: string) => { + // Default no-op for tests without cleanup side effects. + }) + + const terminalResult = await openTerminalFn( + options.worktreePath, + options.launchArgv, + options.branch, + ) + + if (!terminalResult.success) { + await deleteForkedSessionFn(options.forkedSessionId).catch((cleanupError) => { + options.log.warn( + `[worktree] Failed to clean up forked session ${options.forkedSessionId} after launch failure: ${cleanupError}`, + ) + }) + return terminalResult + } + + addSessionFn(options.database, options.sessionRecord) + return terminalResult +} + +/** + * Fork a session and copy associated plans/delegations. + * Cleans up forked session on failure (atomic operation). + */ +async function forkWithContext( + client: OpencodeClient, + sessionId: string, + projectId: string, + getRootSessionIdFn: (sessionId: string) => Promise, +): Promise { + // Guard clauses (Law 1) + if (!client) throw new WorktreeError("client is required", "forkWithContext") + if (!sessionId) throw new WorktreeError("sessionId is required", "forkWithContext") + if (!projectId) throw new WorktreeError("projectId is required", "forkWithContext") + + // Get root session ID with error wrapping + let rootSessionId: string + try { + rootSessionId = await getRootSessionIdFn(sessionId) + } catch (e) { + throw new WorktreeError("Failed to get root session ID", "forkWithContext", e) + } + + // Fork session + const forkedSessionResponse = await client.session.fork({ + path: { id: sessionId }, + body: {}, + }) + const forkedSession = forkedSessionResponse.data + if (!forkedSession?.id) { + throw new WorktreeError("Failed to fork session: no session data returned", "forkWithContext") + } + + // Copy data with cleanup on failure + let planCopied = false + let delegationsCopied = false + + try { + const workspaceBase = path.join(os.homedir(), ".local", "share", "opencode", "workspace") + const delegationsBase = path.join(os.homedir(), ".local", "share", "opencode", "delegations") + + const destWorkspaceDir = path.join(workspaceBase, projectId, forkedSession.id) + const destDelegationsDir = path.join(delegationsBase, projectId, forkedSession.id) + + await mkdir(destWorkspaceDir, { recursive: true }) + await mkdir(destDelegationsDir, { recursive: true }) + + // Copy plan + const srcPlan = path.join(workspaceBase, projectId, rootSessionId, "plan.md") + const destPlan = path.join(destWorkspaceDir, "plan.md") + planCopied = await copyIfExists(srcPlan, destPlan) + + // Copy delegations + const srcDelegations = path.join(delegationsBase, projectId, rootSessionId) + delegationsCopied = await copyDirIfExists(srcDelegations, destDelegationsDir) + } catch (error) { + client.app + .log({ + body: { + service: "worktree", + level: "error", + message: `forkWithContext: Copy failed, cleaning up forked session: ${error}`, + }, + }) + .catch(() => {}) + // Clean up orphaned directories + const workspaceBase = path.join(os.homedir(), ".local", "share", "opencode", "workspace") + const delegationsBase = path.join(os.homedir(), ".local", "share", "opencode", "delegations") + const destWorkspaceDir = path.join(workspaceBase, projectId, forkedSession.id) + const destDelegationsDir = path.join(delegationsBase, projectId, forkedSession.id) + await rm(destWorkspaceDir, { recursive: true, force: true }).catch((e) => { + client.app + .log({ + body: { + service: "worktree", + level: "error", + message: `forkWithContext: Failed to clean up workspace dir ${destWorkspaceDir}: ${e}`, + }, + }) + .catch(() => {}) + }) + await rm(destDelegationsDir, { recursive: true, force: true }).catch((e) => { + client.app + .log({ + body: { + service: "worktree", + level: "error", + message: `forkWithContext: Failed to clean up delegations dir ${destDelegationsDir}: ${e}`, + }, + }) + .catch(() => {}) + }) + await client.session.delete({ path: { id: forkedSession.id } }).catch((e) => { + client.app + .log({ + body: { + service: "worktree", + level: "error", + message: `forkWithContext: Failed to clean up forked session ${forkedSession.id}: ${e}`, + }, + }) + .catch(() => {}) + }) + throw new WorktreeError( + `Failed to copy session data: ${error instanceof Error ? error.message : String(error)}`, + "forkWithContext", + error, + ) + } + + return { forkedSession, rootSessionId, planCopied, delegationsCopied } +} + +// ============================================================================= +// MODULE-LEVEL STATE +// ============================================================================= + +/** Database instance - initialized once per plugin lifecycle */ +let db: Database | null = null + +/** Project root path - stored on first initialization */ +let projectRoot: string | null = null + +/** Flag to prevent duplicate cleanup handler registration */ +let cleanupRegistered = false + +/** + * Register process cleanup handlers for graceful database shutdown. + * Ensures WAL checkpoint and proper close on process termination. + * + * NOTE: process.once() is an EventEmitter method that never throws. + * The boolean guard is defense-in-depth for idempotency, not error recovery. + * + * @param database - The database instance to clean up + */ +function registerCleanupHandlers(database: Database): void { + if (cleanupRegistered) return // Early exit guard + cleanupRegistered = true + + const cleanup = () => { + try { + database.exec("PRAGMA wal_checkpoint(TRUNCATE)") + database.close() + } catch { + // Best effort cleanup - process is exiting anyway + } + } + + process.once("SIGTERM", cleanup) + process.once("SIGINT", cleanup) + process.once("beforeExit", cleanup) +} + +/** + * Get the database instance, initializing if needed. + * Includes retry logic for transient initialization failures. + * + * @returns Database instance + * @throws {Error} if initialization fails after all retries + */ +async function getDb(log: Logger): Promise { + if (db) return db + + if (!projectRoot) { + throw new Error("Database not initialized: projectRoot not set. Call initDb() first.") + } + + let lastError: Error | null = null + + for (let attempt = 1; attempt <= DB_MAX_RETRIES; attempt++) { + try { + db = await initStateDb(projectRoot) + registerCleanupHandlers(db) + return db + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + log.warn(`Database init attempt ${attempt}/${DB_MAX_RETRIES} failed: ${lastError.message}`) + + if (attempt < DB_MAX_RETRIES) { + Bun.sleepSync(DB_RETRY_DELAY_MS) + } + } + } + + throw new Error( + `Failed to initialize database after ${DB_MAX_RETRIES} attempts: ${lastError?.message}`, + ) +} + +/** + * Initialize the database with the project root path. + * Must be called once before any getDb() calls. + */ +async function initDb(root: string, log: Logger): Promise { + projectRoot = root + return getDb(log) +} + +// ============================================================================= +// GIT MODULE +// ============================================================================= + +/** + * Execute a git command safely using Bun.spawn with explicit array. + * Avoids shell interpolation entirely by passing args as array. + */ +async function git(args: string[], cwd: string): Promise> { + try { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (exitCode !== 0) { + return Result.err(stderr.trim() || `git ${args[0]} failed`) + } + return Result.ok(stdout.trim()) + } catch (error) { + return Result.err(error instanceof Error ? error.message : String(error)) + } +} + +async function branchExists(cwd: string, branch: string): Promise { + const result = await git(["rev-parse", "--verify", branch], cwd) + return result.ok +} + +async function createWorktree( + repoRoot: string, + branch: string, + baseBranch?: string, + basePath?: string, +): Promise> { + const worktreePath = await getWorktreePath(repoRoot, branch, basePath) + + // Ensure parent directory exists + await mkdir(path.dirname(worktreePath), { recursive: true }) + + const exists = await branchExists(repoRoot, branch) + + if (exists) { + // Checkout existing branch into worktree + const result = await git(["worktree", "add", worktreePath, branch], repoRoot) + return result.ok ? Result.ok(worktreePath) : result + } else { + // Create new branch from base + const base = baseBranch ?? "HEAD" + const result = await git(["worktree", "add", "-b", branch, worktreePath, base], repoRoot) + return result.ok ? Result.ok(worktreePath) : result + } +} + +async function removeWorktree( + repoRoot: string, + worktreePath: string, +): Promise> { + const result = await git(["worktree", "remove", "--force", worktreePath], repoRoot) + return result.ok ? Result.ok(undefined) : Result.err(result.error) +} + +// ============================================================================= +// FILE SYNC MODULE +// ============================================================================= + +/** + * Validate that a path is safe (no escape from base directory) + */ +function isPathSafe(filePath: string, baseDir: string, log: Logger): boolean { + // Reject absolute paths + if (path.isAbsolute(filePath)) { + log.warn(`[worktree] Rejected absolute path: ${filePath}`) + return false + } + // Reject obvious path traversal + if (filePath.includes("..")) { + log.warn(`[worktree] Rejected path traversal: ${filePath}`) + return false + } + // Verify resolved path stays within base directory + const resolved = path.resolve(baseDir, filePath) + if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) { + log.warn(`[worktree] Path escapes base directory: ${filePath}`) + return false + } + return true +} + +/** + * Copy files from source directory to target directory. + * Skips missing files silently (production pattern). + */ +async function copyFiles( + sourceDir: string, + targetDir: string, + files: string[], + log: Logger, +): Promise { + for (const file of files) { + if (!isPathSafe(file, sourceDir, log)) continue + + const sourcePath = path.join(sourceDir, file) + const targetPath = path.join(targetDir, file) + + try { + const sourceFile = Bun.file(sourcePath) + if (!(await sourceFile.exists())) { + log.debug(`[worktree] Skipping missing file: ${file}`) + continue + } + + // Ensure target directory exists + const targetFileDir = path.dirname(targetPath) + await mkdir(targetFileDir, { recursive: true }) + + // Copy file + await Bun.write(targetPath, sourceFile) + log.info(`[worktree] Copied: ${file}`) + } catch (error) { + const isNotFound = + error instanceof Error && + (error.message.includes("ENOENT") || error.message.includes("no such file")) + if (isNotFound) { + log.debug(`[worktree] Skipping missing: ${file}`) + } else { + log.warn(`[worktree] Failed to copy ${file}: ${error}`) + } + } + } +} + +/** + * Create symlinks for directories from source to target. + * Uses absolute paths for symlink targets. + */ +async function symlinkDirs( + sourceDir: string, + targetDir: string, + dirs: string[], + log: Logger, +): Promise { + for (const dir of dirs) { + if (!isPathSafe(dir, sourceDir, log)) continue + + const sourcePath = path.join(sourceDir, dir) + const targetPath = path.join(targetDir, dir) + + try { + // Check if source directory exists + const fileStat = await stat(sourcePath).catch(() => null) + if (!fileStat || !fileStat.isDirectory()) { + log.debug(`[worktree] Skipping missing directory: ${dir}`) + continue + } + + // Ensure parent directory exists + const targetParentDir = path.dirname(targetPath) + await mkdir(targetParentDir, { recursive: true }) + + // Remove existing target if it exists (might be empty dir from git) + await rm(targetPath, { recursive: true, force: true }) + + // Create symlink (use absolute path for source) + await symlink(sourcePath, targetPath, "dir") + log.info(`[worktree] Symlinked: ${dir}`) + } catch (error) { + log.warn(`[worktree] Failed to symlink ${dir}: ${error}`) + } + } +} + +/** + * Run hook commands in the worktree directory. + */ +async function runHooks(cwd: string, commands: string[], log: Logger): Promise { + for (const command of commands) { + log.info(`[worktree] Running hook: ${command}`) + try { + // Use shell to properly handle quoted arguments and complex commands + const result = Bun.spawnSync(["bash", "-c", command], { + cwd, + stdout: "inherit", + stderr: "pipe", + }) + if (result.exitCode !== 0) { + const stderr = result.stderr?.toString() || "" + log.warn( + `[worktree] Hook failed (exit ${result.exitCode}): ${command}${stderr ? `\n${stderr}` : ""}`, + ) + } + } catch (error) { + log.warn(`[worktree] Hook error: ${error}`) + } + } +} + +/** + * Resolve a path that may contain a leading `~` to the user's home directory. + */ +function resolveHomePath(p: string): string { + if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) { + return path.join(os.homedir(), p.slice(1)) + } + return p +} + +/** + * Load worktree-specific configuration from .opencode/worktree.jsonc + * Auto-creates config file with helpful defaults if it doesn't exist. + */ +async function loadWorktreeConfig(directory: string, log: Logger): Promise { + const configPath = path.join(directory, ".opencode", "worktree.jsonc") + + try { + const file = Bun.file(configPath) + if (!(await file.exists())) { + // Auto-create config with helpful defaults and comments + const defaultConfig = `{ + "$schema": "https://registry.kdco.dev/schemas/worktree.json", + + // Worktree plugin configuration + // Documentation: https://github.com/kdcokenny/ocx + + // Custom base path for worktree storage (supports ~) + // Default: ~/.local/share/opencode/worktree + // "worktreePath": "~/my-worktrees", + + "sync": { + // Files to copy from main worktree to new worktrees + // Example: [".env", ".env.local", "dev.sqlite"] + "copyFiles": [], + + // Directories to symlink (saves disk space) + // Example: ["node_modules"] + "symlinkDirs": [], + + // Patterns to exclude from copying + "exclude": [] + }, + + "hooks": { + // Commands to run after worktree creation + // Example: ["pnpm install", "docker compose up -d"] + "postCreate": [], + + // Commands to run before worktree deletion + // Example: ["docker compose down"] + "preDelete": [] + } +} +` + // Ensure .opencode directory exists + await mkdir(path.join(directory, ".opencode"), { recursive: true }) + await Bun.write(configPath, defaultConfig) + log.info(`[worktree] Created default config: ${configPath}`) + return worktreeConfigSchema.parse({}) + } + + const content = await file.text() + // Use proper JSONC parser (handles comments in strings correctly) + const parsed = parseJsonc(content) + if (parsed === undefined) { + log.error(`[worktree] Invalid worktree.jsonc syntax`) + return worktreeConfigSchema.parse({}) + } + const config = worktreeConfigSchema.parse(parsed) + if (config.worktreePath) { + config.worktreePath = resolveHomePath(config.worktreePath) + } + return config + } catch (error) { + log.warn(`[worktree] Failed to load config: ${error}`) + return worktreeConfigSchema.parse({}) + } +} + +// ============================================================================= +// PLUGIN ENTRY +// ============================================================================= + +const WorktreePlugin: Plugin = async (ctx) => { + const { directory, client } = ctx + + const log = { + debug: (msg: string) => + client.app + .log({ body: { service: "worktree", level: "debug", message: msg } }) + .catch(() => {}), + info: (msg: string) => + client.app + .log({ body: { service: "worktree", level: "info", message: msg } }) + .catch(() => {}), + warn: (msg: string) => + client.app + .log({ body: { service: "worktree", level: "warn", message: msg } }) + .catch(() => {}), + error: (msg: string) => + client.app + .log({ body: { service: "worktree", level: "error", message: msg } }) + .catch(() => {}), + } + + // Initialize SQLite database + const database = await initDb(directory, log) + + return { + tool: { + worktree_create: tool({ + description: + "Create a new git worktree for isolated development. A new terminal will open with OpenCode in the worktree.", + args: { + branch: tool.schema + .string() + .describe("Branch name for the worktree (e.g., 'feature/dark-mode')"), + baseBranch: tool.schema + .string() + .optional() + .describe("Base branch to create from (defaults to HEAD)"), + }, + async execute(args, toolCtx) { + // Validate branch name at boundary + const branchResult = branchNameSchema.safeParse(args.branch) + if (!branchResult.success) { + return `❌ Invalid branch name: ${branchResult.error.issues[0]?.message}` + } + + // Validate base branch name at boundary + if (args.baseBranch) { + const baseResult = branchNameSchema.safeParse(args.baseBranch) + if (!baseResult.success) { + return `❌ Invalid base branch name: ${baseResult.error.issues[0]?.message}` + } + } + + let activeLaunchContext: ActiveLaunchContext + try { + activeLaunchContext = parseActiveLaunchContext( + process.env as Record, + ) + activeLaunchContext = await ensureLaunchContextExecutable( + activeLaunchContext, + directory, + ) + await ensureLaunchContextProfile(activeLaunchContext) + } catch (error) { + return `❌ ${error instanceof Error ? error.message : String(error)}` + } + + // Load config first so worktreePath is available for createWorktree + const worktreeConfig = await loadWorktreeConfig(directory, log) + + // Create worktree + const result = await createWorktree( + directory, + args.branch, + args.baseBranch, + worktreeConfig.worktreePath, + ) + if (!result.ok) { + return `Failed to create worktree: ${result.error}` + } + + const worktreePath = result.value + + // Sync files from main worktree + const mainWorktreePath = directory // The repo root is the main worktree + + // Copy files + if (worktreeConfig.sync.copyFiles.length > 0) { + await copyFiles(mainWorktreePath, worktreePath, worktreeConfig.sync.copyFiles, log) + } + + // Symlink directories + if (worktreeConfig.sync.symlinkDirs.length > 0) { + await symlinkDirs(mainWorktreePath, worktreePath, worktreeConfig.sync.symlinkDirs, log) + } + + // Run postCreate hooks + if (worktreeConfig.hooks.postCreate.length > 0) { + await runHooks(worktreePath, worktreeConfig.hooks.postCreate, log) + } + + // Fork session with context (replaces --session resume) + const projectId = await getProjectId(worktreePath, client) + const { forkedSession, planCopied, delegationsCopied } = await forkWithContext( + client, + toolCtx.sessionID, + projectId, + async (sid) => { + // Walk up parentID chain to find root session + let currentId = sid + for (let depth = 0; depth < MAX_SESSION_CHAIN_DEPTH; depth++) { + const session = await client.session.get({ path: { id: currentId } }) + if (!session.data?.parentID) return currentId + currentId = session.data.parentID + } + return currentId + }, + ) + + log.debug( + `Forked session ${forkedSession.id}, plan: ${planCopied}, delegations: ${delegationsCopied}`, + ) + const persistedLaunchMetadata = toPersistedLaunchMetadata(activeLaunchContext) + const launchArgv = buildSessionLaunchArgv(forkedSession.id, persistedLaunchMetadata) + const serializedLaunchMetadata = serializePersistedLaunchMetadata(persistedLaunchMetadata) + + const terminalResult = await finalizeWorktreeLaunch({ + database, + worktreePath, + launchArgv, + branch: args.branch, + forkedSessionId: forkedSession.id, + sessionRecord: { + id: forkedSession.id, + branch: args.branch, + path: worktreePath, + createdAt: new Date().toISOString(), + launchMode: serializedLaunchMetadata.launchMode, + profile: serializedLaunchMetadata.profile, + ocxBin: serializedLaunchMetadata.ocxBin, + }, + log, + deleteForkedSessionFn: async (sessionId: string) => { + await client.session.delete({ path: { id: sessionId } }) + }, + }) + + if (!terminalResult.success) { + return `❌ Failed to launch worktree terminal: ${terminalResult.error ?? "unknown error"}\nWorktree created at ${worktreePath}. Verify launch settings and retry.` + } + + return `Worktree created at ${worktreePath}\n\nA new terminal has been opened with OpenCode.` + }, + }), + + worktree_delete: tool({ + description: + "Delete the current worktree and clean up. Changes will be committed before removal.", + args: { + reason: tool.schema + .string() + .describe("Brief explanation of why you are calling this tool"), + }, + async execute(_args, toolCtx) { + // Find current session's worktree + const session = getSession(database, toolCtx?.sessionID ?? "") + if (!session) { + return `No worktree associated with this session` + } + + // Set pending delete for session.idle (atomic operation) + setPendingDelete(database, { branch: session.branch, path: session.path }, client) + + return `Worktree marked for cleanup. It will be removed when this session ends.` + }, + }), + }, + + event: async ({ event }: { event: Event }): Promise => { + if (event.type !== "session.idle") return + + // Handle pending delete + const pendingDelete = getPendingDelete(database) + if (pendingDelete) { + const { path: worktreePath, branch } = pendingDelete + + // Run preDelete hooks before cleanup + const config = await loadWorktreeConfig(directory, log) + if (config.hooks.preDelete.length > 0) { + await runHooks(worktreePath, config.hooks.preDelete, log) + } + + // Commit any uncommitted changes + const addResult = await git(["add", "-A"], worktreePath) + if (!addResult.ok) log.warn(`[worktree] git add failed: ${addResult.error}`) + + const commitResult = await git( + ["commit", "-m", "chore(worktree): session snapshot", "--allow-empty"], + worktreePath, + ) + if (!commitResult.ok) log.warn(`[worktree] git commit failed: ${commitResult.error}`) + + // Remove worktree + const removeResult = await removeWorktree(directory, worktreePath) + if (!removeResult.ok) { + log.warn(`[worktree] Failed to remove worktree: ${removeResult.error}`) + } + + // Clear pending delete atomically + clearPendingDelete(database) + + // Remove session from database + removeSession(database, branch) + } + }, + } +} + +const WorktreePluginWithInternals = Object.assign(WorktreePlugin, { + testInternals: { + isPathLikeCommand, + ensureLaunchContextExecutable, + validateOcxProfileAvailability, + ensureLaunchContextProfile, + finalizeWorktreeLaunch, + }, +} as const) + +export default WorktreePluginWithInternals diff --git a/.opencode/plugins/worktree/launch-context.ts b/.opencode/plugins/worktree/launch-context.ts new file mode 100644 index 0000000..e766d37 --- /dev/null +++ b/.opencode/plugins/worktree/launch-context.ts @@ -0,0 +1,153 @@ +import { z } from "zod" + +export type ActiveLaunchContext = + | { mode: "plain" } + | { mode: "ocx"; ocxBin: string; profile: string } + +export type PersistedLaunchMetadata = + | { mode: "plain" } + | { mode: "ocx"; ocxBin: string; profile: string } + +export interface PersistedLaunchMetadataInput { + launchMode?: string | null + ocxBin?: string | null + profile?: string | null +} + +const ocxContextMarkerSchema = z.literal("1") + +function normalizeOptionalNonEmpty(value: string | null | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +function requireNonEmptyField( + value: string | undefined, + fieldName: string, + source: string, +): string { + if (value) { + return value + } + + throw new Error(`${source} requires ${fieldName} to be set to a non-empty value`) +} + +export function parseActiveLaunchContext( + env: Record = process.env, +): ActiveLaunchContext { + const markerResult = ocxContextMarkerSchema.safeParse(env.OCX_CONTEXT?.trim()) + if (!markerResult.success) { + return { mode: "plain" } + } + + const ocxBin = requireNonEmptyField( + normalizeOptionalNonEmpty(env.OCX_BIN), + "OCX_BIN", + "Invalid OCX launch context (OCX_CONTEXT=1)", + ) + const profile = requireNonEmptyField( + normalizeOptionalNonEmpty(env.OCX_PROFILE), + "OCX_PROFILE", + "Invalid OCX launch context (OCX_CONTEXT=1)", + ) + + return { + mode: "ocx", + ocxBin, + profile, + } +} + +export function parsePersistedLaunchMetadata( + input: PersistedLaunchMetadataInput, +): PersistedLaunchMetadata { + const launchMode = normalizeOptionalNonEmpty(input.launchMode) + + // Legacy rows did not persist launch metadata - treat as plain for backward compatibility. + if (!launchMode) { + return { mode: "plain" } + } + + if (launchMode === "plain") { + return { mode: "plain" } + } + + if (launchMode !== "ocx") { + throw new Error(`Invalid persisted launch metadata: unsupported launchMode "${launchMode}"`) + } + + const ocxBin = requireNonEmptyField( + normalizeOptionalNonEmpty(input.ocxBin), + "ocxBin", + "Invalid persisted launch metadata (launchMode=ocx)", + ) + const profile = requireNonEmptyField( + normalizeOptionalNonEmpty(input.profile), + "profile", + "Invalid persisted launch metadata (launchMode=ocx)", + ) + + return { + mode: "ocx", + ocxBin, + profile, + } +} + +export function buildSessionLaunchArgv( + sessionID: string, + launchMetadata: ActiveLaunchContext | PersistedLaunchMetadata, +): string[] { + const normalizedSessionID = normalizeOptionalNonEmpty(sessionID) + if (!normalizedSessionID) { + throw new Error("Session id is required to build launch argv") + } + + if (launchMetadata.mode === "plain") { + return ["opencode", "--session", normalizedSessionID] + } + + return [ + launchMetadata.ocxBin, + "opencode", + "-p", + launchMetadata.profile, + "--session", + normalizedSessionID, + ] +} + +export function toPersistedLaunchMetadata( + launchContext: ActiveLaunchContext, +): PersistedLaunchMetadata { + if (launchContext.mode === "plain") { + return { mode: "plain" } + } + + return { + mode: "ocx", + ocxBin: launchContext.ocxBin, + profile: launchContext.profile, + } +} + +export function serializePersistedLaunchMetadata(metadata: PersistedLaunchMetadata): { + launchMode: "plain" | "ocx" + profile: string | null + ocxBin: string | null +} { + if (metadata.mode === "plain") { + return { + launchMode: "plain", + profile: null, + ocxBin: null, + } + } + + return { + launchMode: "ocx", + profile: metadata.profile, + ocxBin: metadata.ocxBin, + } +} diff --git a/.opencode/plugins/worktree/state.ts b/.opencode/plugins/worktree/state.ts new file mode 100644 index 0000000..230adf2 --- /dev/null +++ b/.opencode/plugins/worktree/state.ts @@ -0,0 +1,502 @@ +/** + * SQLite State Module for Worktree Plugin + * + * Provides atomic, crash-safe persistence for worktree sessions and pending operations. + * Uses bun:sqlite for zero external dependencies. + * + * Database location: ~/.local/share/opencode/plugins/worktree/{project-id}.sqlite + * Project ID is the first git root commit SHA (40-char hex), with SHA-256 path hash fallback (16-char). + */ + +import { Database } from "bun:sqlite" +import { mkdirSync } from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { z } from "zod" +import type { OpencodeClient } from "../kdco-primitives" +import { getProjectId, logWarn } from "../kdco-primitives" +import { parsePersistedLaunchMetadata, serializePersistedLaunchMetadata } from "./launch-context" + +// ============================================================================= +// TYPES +// ============================================================================= + +/** Represents an active worktree session */ +export interface Session { + id: string + branch: string + path: string + createdAt: string + launchMode: "plain" | "ocx" + profile: string | null + ocxBin: string | null +} + +export type SessionInput = Omit & { + launchMode?: "plain" | "ocx" + profile?: string | null + ocxBin?: string | null +} + +/** Pending spawn operation to be processed on session.idle */ +export interface PendingSpawn { + branch: string + path: string + sessionId: string +} + +/** Pending delete operation to be processed on session.idle */ +export interface PendingDelete { + branch: string + path: string +} + +// ============================================================================= +// SCHEMAS (Boundary Validation) +// ============================================================================= + +const sessionSchema = z.object({ + id: z.string().min(1), + branch: z.string().min(1), + path: z.string().min(1), + createdAt: z.string().min(1), + launchMode: z.enum(["plain", "ocx"]).optional(), + profile: z.string().nullable().optional(), + ocxBin: z.string().nullable().optional(), +}) + +const pendingSpawnSchema = z.object({ + branch: z.string().min(1), + path: z.string().min(1), + sessionId: z.string().min(1), +}) + +const pendingDeleteSchema = z.object({ + branch: z.string().min(1), + path: z.string().min(1), +}) + +// ============================================================================= +// DATABASE UTILITIES +// ============================================================================= + +/** + * Get the default base directory for worktree storage. + * Location: ~/.local/share/opencode/worktree/ + */ +function getWorktreeBaseDirectory(): string { + return path.join(os.homedir(), ".local", "share", "opencode", "worktree") +} + +/** + * Get the worktree path for a given project and branch. + * + * @param projectRoot - Absolute path to the project root + * @param branch - Branch name for the worktree + * @param basePath - Optional custom base path (absolute). Defaults to ~/.local/share/opencode/worktree + * @returns Absolute path to the worktree directory + */ +export async function getWorktreePath( + projectRoot: string, + branch: string, + basePath?: string, +): Promise { + if (!branch || typeof branch !== "string") { + throw new Error("branch is required") + } + const projectId = await getProjectId(projectRoot) + return path.join(basePath ?? getWorktreeBaseDirectory(), projectId, branch) +} + +/** + * Get the database directory path. + * Location: ~/.local/share/opencode/plugins/worktree/ + */ +function getDbDirectory(): string { + const home = os.homedir() + return path.join(home, ".local", "share", "opencode", "plugins", "worktree") +} + +/** + * Get the full database file path for a project. + * @param projectRoot - Absolute path to the project root + */ +async function getDbPath(projectRoot: string): Promise { + const projectId = await getProjectId(projectRoot) + return path.join(getDbDirectory(), `${projectId}.sqlite`) +} + +/** + * Initialize the SQLite database for worktree state. + * Creates the database file and schema if they don't exist. + * + * @param projectRoot - Absolute path to the project root + * @returns Configured Database instance + * + * @example + * ```ts + * const db = await initStateDb("/home/user/my-project") + * const sessions = getAllSessions(db) + * db.close() + * ``` + */ +export async function initStateDb(projectRoot: string): Promise { + // Guard: validate project root + if (!projectRoot || typeof projectRoot !== "string") { + throw new Error("initStateDb requires a valid project root path") + } + + const dbPath = await getDbPath(projectRoot) + const dbDir = path.dirname(dbPath) + + // Create directory synchronously (required before opening DB) + mkdirSync(dbDir, { recursive: true }) + + // Open database (creates if doesn't exist) + const db = new Database(dbPath) + + // Configure SQLite for concurrent access + db.exec("PRAGMA journal_mode=WAL") + db.exec("PRAGMA busy_timeout=5000") + + // Create tables with schema + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + branch TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL, + launch_mode TEXT, + profile TEXT, + ocx_bin TEXT + ) + `) + + ensureSessionLaunchMetadataColumns(db) + + db.exec(` + CREATE TABLE IF NOT EXISTS pending_operations ( + id INTEGER PRIMARY KEY CHECK (id = 1), + type TEXT NOT NULL, + branch TEXT NOT NULL, + path TEXT NOT NULL, + session_id TEXT + ) + `) + + return db +} + +function ensureSessionLaunchMetadataColumns(db: Database): void { + const tableInfo = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name?: string }> + const sessionColumns = new Set(tableInfo.map((column) => column.name).filter(Boolean)) + + if (!sessionColumns.has("launch_mode")) { + addSessionColumn(db, "launch_mode", "ALTER TABLE sessions ADD COLUMN launch_mode TEXT") + } + + if (!sessionColumns.has("profile")) { + addSessionColumn(db, "profile", "ALTER TABLE sessions ADD COLUMN profile TEXT") + } + + if (!sessionColumns.has("ocx_bin")) { + addSessionColumn(db, "ocx_bin", "ALTER TABLE sessions ADD COLUMN ocx_bin TEXT") + } +} + +function addSessionColumn(db: Database, columnName: string, sql: string): void { + try { + db.exec(sql) + } catch (error) { + if (isDuplicateColumnError(error, columnName)) { + return + } + + throw error + } +} + +function isDuplicateColumnError(error: unknown, columnName: string): boolean { + if (!(error instanceof Error)) { + return false + } + + const normalizedMessage = error.message.toLowerCase() + return ( + normalizedMessage.includes("duplicate column name") && + normalizedMessage.includes(columnName.toLowerCase()) + ) +} + +function normalizeSessionRow(row: Record): Session { + const launchMetadata = parsePersistedLaunchMetadata({ + launchMode: row.launchMode, + profile: row.profile, + ocxBin: row.ocxBin, + }) + const serialized = serializePersistedLaunchMetadata(launchMetadata) + + return { + id: String(row.id), + branch: String(row.branch), + path: String(row.path), + createdAt: String(row.createdAt), + launchMode: serialized.launchMode, + profile: serialized.profile, + ocxBin: serialized.ocxBin, + } +} + +// ============================================================================= +// SESSION CRUD +// ============================================================================= + +/** + * Add a new session to the database. + * Uses atomic INSERT OR REPLACE for idempotency. + * + * @param db - Database instance from initStateDb + * @param session - Session data to persist + */ +export function addSession(db: Database, session: SessionInput): void { + // Parse at boundary for type safety + const parsed = sessionSchema.parse(session) + const launchMetadata = parsePersistedLaunchMetadata({ + launchMode: parsed.launchMode, + profile: parsed.profile, + ocxBin: parsed.ocxBin, + }) + const serializedLaunchMetadata = serializePersistedLaunchMetadata(launchMetadata) + + const stmt = db.prepare(` + INSERT OR REPLACE INTO sessions (id, branch, path, created_at, launch_mode, profile, ocx_bin) + VALUES ($id, $branch, $path, $createdAt, $launchMode, $profile, $ocxBin) + `) + + stmt.run({ + $id: parsed.id, + $branch: parsed.branch, + $path: parsed.path, + $createdAt: parsed.createdAt, + $launchMode: serializedLaunchMetadata.launchMode, + $profile: serializedLaunchMetadata.profile, + $ocxBin: serializedLaunchMetadata.ocxBin, + }) +} + +/** + * Get a session by ID. + * + * @param db - Database instance from initStateDb + * @param sessionId - Session ID to look up + * @returns Session if found, null otherwise + */ +export function getSession(db: Database, sessionId: string): Session | null { + // Guard: empty session ID + if (!sessionId) return null + + const stmt = db.prepare(` + SELECT id, branch, path, created_at as createdAt, launch_mode as launchMode, profile, ocx_bin as ocxBin + FROM sessions + WHERE id = $id + `) + + const row = stmt.get({ $id: sessionId }) as Record | null + if (!row) return null + + return normalizeSessionRow(row) +} + +/** + * Remove a session by branch name. + * Deletes all sessions matching the branch. + * + * @param db - Database instance from initStateDb + * @param branch - Branch name to remove + */ +export function removeSession(db: Database, branch: string): void { + // Guard: empty branch + if (!branch) return + + const stmt = db.prepare(`DELETE FROM sessions WHERE branch = $branch`) + stmt.run({ $branch: branch }) +} + +/** + * Get all active sessions. + * + * @param db - Database instance from initStateDb + * @returns Array of all sessions, empty if none + */ +export function getAllSessions(db: Database): Session[] { + const stmt = db.prepare(` + SELECT id, branch, path, created_at as createdAt, launch_mode as launchMode, profile, ocx_bin as ocxBin + FROM sessions + ORDER BY created_at ASC + `) + + const rows = stmt.all() as Array> + return rows.map((row) => normalizeSessionRow(row)) +} + +// ============================================================================= +// PENDING SPAWN OPERATIONS +// ============================================================================= + +/** + * Set a pending spawn operation. Uses singleton pattern (last-write-wins). + * + * If a pending spawn already exists, it will be REPLACED and a warning logged. + * This is intentional: only the most recent spawn request should be processed. + * + * @param db - Database instance from initStateDb + * @param spawn - Spawn operation data + */ +export function setPendingSpawn(db: Database, spawn: PendingSpawn, client?: OpencodeClient): void { + // Parse at boundary for type safety + const parsed = pendingSpawnSchema.parse(spawn) + + // Check for existing operations and warn about replacement + const existingSpawn = getPendingSpawn(db) + const existingDelete = getPendingDelete(db) + + if (existingSpawn) { + logWarn( + client, + "worktree", + `Replacing pending spawn: "${existingSpawn.branch}" β†’ "${parsed.branch}"`, + ) + } else if (existingDelete) { + logWarn( + client, + "worktree", + `Pending spawn replacing pending delete for: "${existingDelete.branch}"`, + ) + } + + // Atomic: replace any existing pending operation + const stmt = db.prepare(` + INSERT OR REPLACE INTO pending_operations (id, type, branch, path, session_id) + VALUES (1, 'spawn', $branch, $path, $sessionId) + `) + + stmt.run({ + $branch: parsed.branch, + $path: parsed.path, + $sessionId: parsed.sessionId, + }) +} + +/** + * Get the pending spawn operation if one exists. + * + * @param db - Database instance from initStateDb + * @returns PendingSpawn if exists and type is 'spawn', null otherwise + */ +export function getPendingSpawn(db: Database): PendingSpawn | null { + const stmt = db.prepare(` + SELECT type, branch, path, session_id as sessionId + FROM pending_operations + WHERE id = 1 AND type = 'spawn' + `) + + const row = stmt.get() as Record | null + if (!row) return null + + return { + branch: row.branch, + path: row.path, + sessionId: row.sessionId, + } +} + +/** + * Clear any pending spawn operation. + * Removes the row if it's a spawn type, leaves deletes untouched. + * + * @param db - Database instance from initStateDb + */ +export function clearPendingSpawn(db: Database): void { + const stmt = db.prepare(`DELETE FROM pending_operations WHERE id = 1 AND type = 'spawn'`) + stmt.run() +} + +// ============================================================================= +// PENDING DELETE OPERATIONS +// ============================================================================= + +/** + * Set a pending delete operation. Uses singleton pattern (last-write-wins). + * + * If a pending delete already exists, it will be REPLACED and a warning logged. + * This is intentional: only the most recent delete request should be processed. + * + * @param db - Database instance from initStateDb + * @param del - Delete operation data + */ +export function setPendingDelete(db: Database, del: PendingDelete, client?: OpencodeClient): void { + // Parse at boundary for type safety + const parsed = pendingDeleteSchema.parse(del) + + // Check for existing operations and warn about replacement + const existingDelete = getPendingDelete(db) + const existingSpawn = getPendingSpawn(db) + + if (existingDelete) { + logWarn( + client, + "worktree", + `Replacing pending delete: "${existingDelete.branch}" β†’ "${parsed.branch}"`, + ) + } else if (existingSpawn) { + logWarn( + client, + "worktree", + `Pending delete replacing pending spawn for: "${existingSpawn.branch}"`, + ) + } + + // Atomic: replace any existing pending operation + const stmt = db.prepare(` + INSERT OR REPLACE INTO pending_operations (id, type, branch, path, session_id) + VALUES (1, 'delete', $branch, $path, NULL) + `) + + stmt.run({ + $branch: parsed.branch, + $path: parsed.path, + }) +} + +/** + * Get the pending delete operation if one exists. + * + * @param db - Database instance from initStateDb + * @returns PendingDelete if exists and type is 'delete', null otherwise + */ +export function getPendingDelete(db: Database): PendingDelete | null { + const stmt = db.prepare(` + SELECT type, branch, path + FROM pending_operations + WHERE id = 1 AND type = 'delete' + `) + + const row = stmt.get() as Record | null + if (!row) return null + + return { + branch: row.branch, + path: row.path, + } +} + +/** + * Clear any pending delete operation. + * Removes the row if it's a delete type, leaves spawns untouched. + * + * @param db - Database instance from initStateDb + */ +export function clearPendingDelete(db: Database): void { + const stmt = db.prepare(`DELETE FROM pending_operations WHERE id = 1 AND type = 'delete'`) + stmt.run() +} diff --git a/.opencode/plugins/worktree/terminal.ts b/.opencode/plugins/worktree/terminal.ts new file mode 100644 index 0000000..b57cfc4 --- /dev/null +++ b/.opencode/plugins/worktree/terminal.ts @@ -0,0 +1,1334 @@ +/** + * Terminal Module for Worktree Plugin + * + * Provides mutex-protected tmux operations and cross-platform terminal spawning. + * Serializes tmux commands to prevent socket races since tmux server is single-threaded. + * + * This module is extracted from worktree.ts to provide a focused, testable + * interface for terminal operations with proper concurrency control. + */ + +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { z } from "zod" +import type { CmuxContext, CmuxEnvironment, OpencodeClient, ResolveExecutable } from "../kdco-primitives" +import { + canUseCmuxWorkflow, + detectCmuxContext, + escapeAppleScript, + escapeBash, + escapeBatch, + getTempDir, + isInsideTmux, + logWarn, + Mutex, + TimeoutError, + withTimeout, +} from "../kdco-primitives" + +export { + canUseCmuxWorkflow, + detectCmuxContext, + type CmuxContext, + type CmuxEnvironment, + type ResolveExecutable, +} from "../kdco-primitives" + +// ============================================================================= +// TEMP SCRIPT HELPER +// ============================================================================= + +/** + * Execute a function with a temporary script file that is guaranteed to be cleaned up. + * Uses try-finally pattern to ensure cleanup even on errors. + * + * @param scriptContent - Content to write to the temp script + * @param fn - Function to execute with the script path + * @param extension - File extension for the script (default: ".sh") + * @returns Result of the function execution + */ +export async function withTempScript( + scriptContent: string, + fn: (scriptPath: string) => Promise, + extension: string = ".sh", + client?: OpencodeClient, +): Promise { + const scriptPath = path.join( + getTempDir(), + `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}${extension}`, + ) + await Bun.write(scriptPath, scriptContent) + await fs.chmod(scriptPath, 0o755) + + try { + return await fn(scriptPath) + } finally { + try { + if (await Bun.file(scriptPath).exists()) { + await fs.rm(scriptPath) + } + } catch (cleanupError) { + // Log but don't throw - cleanup is best-effort + logWarn(client, "worktree", `Failed to cleanup temp script: ${scriptPath}: ${cleanupError}`) + } + } +} + +/** + * Wrap a bash script with trap-based self-cleanup. + * The script deletes itself on ANY exit (success, error, or signal). + * This eliminates race conditions with detached processes. + */ +function wrapWithSelfCleanup(script: string): string { + return `#!/bin/bash +trap 'rm -f "$0"' EXIT INT TERM +${script}` +} + +/** + * Wrap a batch script with self-cleanup. + * Uses goto trick to delete itself after execution. + */ +function wrapBatchWithSelfCleanup(script: string): string { + return `@echo off +${script} +(goto) 2>nul & del "%~f0"` +} + +/** Build Warp launch configuration YAML for Linux. */ +function buildWarpLaunchConfigYaml( + name: string, + cwd: string, + configPath: string, + command?: string, +): string { + const quotedName = JSON.stringify(name) + const quotedCwd = JSON.stringify(cwd) + const cleanupCommand = `rm -f "${escapeBash(configPath)}"` + const commands = [cleanupCommand] + if (command) { + commands.push(command) + } + const commandsBlock = `\n commands:${commands + .map((cmd) => `\n - exec: ${JSON.stringify(cmd)}`) + .join("")}` + + return `--- +name: ${quotedName} +active_window_index: 0 +windows: + - active_tab_index: 0 + tabs: + - layout: + cwd: ${quotedCwd}${commandsBlock} +` +} + +/** Get Warp launch configuration directory for current platform user. */ +function getWarpLaunchConfigDir(): string { + const xdgDataHome = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share") + return path.join(xdgDataHome, "warp-terminal", "launch_configurations") +} + +// ============================================================================= +// TYPES +// ============================================================================= + +/** Terminal type for the current platform */ +export type TerminalType = "tmux" | "cmux" | "macos" | "windows" | "linux-desktop" + +/** Result of a terminal operation */ +export interface TerminalResult { + success: boolean + error?: string +} + +function normalizeArgv(argv?: string[]): string[] { + if (!argv) { + return [] + } + + return argv +} + +export function buildBashCommandFromArgv(argv?: string[]): string | undefined { + const normalizedArgv = normalizeArgv(argv) + if (normalizedArgv.length === 0) { + return undefined + } + + return normalizedArgv.map((arg) => `"${escapeBash(arg)}"`).join(" ") +} + +export function buildBatchCommandFromArgv(argv?: string[]): string | undefined { + const normalizedArgv = normalizeArgv(argv) + if (normalizedArgv.length === 0) { + return undefined + } + + return normalizedArgv.map((arg) => `"${escapeBatch(arg).replace(/"/g, '""')}"`).join(" ") +} + +type CmuxCommandResult = { + exitCode: number + stderr: string +} +type RunCmuxCommand = (args: string[]) => CmuxCommandResult | Promise + +export interface CmuxTerminalExecutionResult { + terminalResult: TerminalResult + hasStateMutation: boolean +} + +const CMUX_COMMAND_TIMEOUT_MS = 1500 + +// Singleton mutex for all tmux operations in this process +const tmuxMutex = new Mutex() + +/** Stabilization delay after spawning tmux windows (ms) */ +const STABILIZATION_DELAY_MS = 150 + +// ============================================================================= +// ENVIRONMENT DETECTION SCHEMAS +// ============================================================================= + +/** Validates WSL environment detection */ +const wslEnvSchema = z.object({ + WSL_DISTRO_NAME: z.string().optional(), + WSLENV: z.string().optional(), +}) + +/** Validates Linux terminal environment detection */ +const linuxTerminalEnvSchema = z.object({ + KITTY_WINDOW_ID: z.string().optional(), + WEZTERM_PANE: z.string().optional(), + ALACRITTY_WINDOW_ID: z.string().optional(), + GHOSTTY_RESOURCES_DIR: z.string().optional(), + TERM_PROGRAM: z.string().optional(), + GNOME_TERMINAL_SERVICE: z.string().optional(), + KONSOLE_VERSION: z.string().optional(), +}) + +/** Environment variables for macOS terminal detection */ +const macTerminalEnvSchema = z.object({ + TERM_PROGRAM: z.string().optional(), + GHOSTTY_RESOURCES_DIR: z.string().optional(), + ITERM_SESSION_ID: z.string().optional(), + KITTY_WINDOW_ID: z.string().optional(), + ALACRITTY_WINDOW_ID: z.string().optional(), + __CFBundleIdentifier: z.string().optional(), +}) + +type LinuxTerminal = + | "kitty" + | "wezterm" + | "alacritty" + | "ghostty" + | "warp" + | "foot" + | "gnome-terminal" + | "konsole" + | "xfce4-terminal" + | "xdg-terminal-exec" + | "x-terminal-emulator" + | "xterm" + +type MacTerminal = "ghostty" | "iterm" | "warp" | "kitty" | "alacritty" | "terminal" + +// ============================================================================= +// PLATFORM DETECTION +// ============================================================================= + +/** + * Check if running inside WSL (Windows Subsystem for Linux). + * Checks environment variables and os.release() for Microsoft string. + */ +function isInsideWSL(): boolean { + const parsed = wslEnvSchema.safeParse(process.env) + if (parsed.success && (parsed.data.WSL_DISTRO_NAME || parsed.data.WSLENV)) { + return true + } + + // Fallback: check os.release() for Microsoft string + try { + return os.release().toLowerCase().includes("microsoft") + } catch { + return false + } +} + +type PlatformTerminalType = Exclude + +function detectPlatformTerminalType(): PlatformTerminalType { + // WSL check (Linux inside Windows) - before platform detection + if (process.platform === "linux" && isInsideWSL()) { + return "windows" // Use Windows Terminal via interop + } + + // Platform-specific + switch (process.platform) { + case "darwin": + return "macos" + case "win32": + return "windows" + case "linux": + return "linux-desktop" + default: + return "linux-desktop" + } +} + +/** + * Detect the best terminal type for the current platform. + * Priority: tmux > cmux > WSL/platform-specific + * + * @returns The detected terminal type + */ +export function detectTerminalType(): TerminalType { + // tmux takes priority - user may be inside tmux on any platform + if (isInsideTmux()) { + return "tmux" + } + + if (canUseCmuxWorkflow()) { + return "cmux" + } + + return detectPlatformTerminalType() +} + +// ============================================================================= +// TMUX OPERATIONS (MUTEX-PROTECTED) +// ============================================================================= + +/** + * Open a new tmux window with mutex protection. + * Includes stabilization delay after spawning to prevent races. + * + * SECURITY NOTE: Branch names and paths are passed via array-based spawn + * (Bun.spawnSync with array arguments), NOT shell string interpolation. + * This prevents command injection even if values contain special characters. + * The tmux `-n` flag treats its argument as a literal window name string. + * + * @param options - Window configuration + * @param options.sessionName - Optional tmux session name (uses current session if not specified) + * @param options.windowName - Name for the new window + * @param options.cwd - Working directory for the window + * @param options.command - Optional command to execute in the window + * @returns Success status and optional error message + * + * @example + * ```ts + * const result = await openTmuxWindow({ + * windowName: "feature-branch", + * cwd: "/path/to/worktree", + * command: "opencode --session abc123", + * }) + * if (!result.success) { + * console.error(result.error) + * } + * ``` + */ +export async function openTmuxWindow(options: { + sessionName?: string + windowName: string + cwd: string + argv?: string[] +}): Promise { + const { sessionName, windowName, cwd, argv } = options + const command = buildBashCommandFromArgv(argv) + + return tmuxMutex.runExclusive(async () => { + try { + // Build tmux new-window command + const tmuxArgs = ["new-window", "-n", windowName, "-c", cwd, "-P", "-F", "#{pane_id}"] + + // Add session target if specified + if (sessionName) { + tmuxArgs.splice(1, 0, "-t", sessionName) + } + + // If there's a command to run, create script first and pass it to new-window + if (command) { + const scriptPath = path.join(getTempDir(), `worktree-${Bun.randomUUIDv7()}.sh`) + const escapedCwd = escapeBash(cwd) + const scriptContent = wrapWithSelfCleanup( + `cd "${escapedCwd}" || exit 1 +${command} +exec $SHELL`, + ) + await Bun.write(scriptPath, scriptContent) + Bun.spawnSync(["chmod", "+x", scriptPath]) + + // Add script execution to tmux args + tmuxArgs.push("--", "bash", scriptPath) + } + + const createResult = Bun.spawnSync(["tmux", ...tmuxArgs]) + + if (createResult.exitCode !== 0) { + return { + success: false, + error: `Failed to create tmux window: ${createResult.stderr.toString()}`, + } + } + + // Stabilization delay to let tmux server process the window + await Bun.sleep(STABILIZATION_DELAY_MS) + + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + }) +} + +// ============================================================================= +// CMUX OPERATIONS +// ============================================================================= + +export function buildCmuxCommandSequence( + _context: CmuxContext, + cwd: string, + argv?: string[], +): string[][] { + // Product policy: each worktree launch gets a new cmux workspace. + // We intentionally do not reuse the current workspace context. + const cmuxArgs = ["new-workspace", "--cwd", cwd] + const command = buildBashCommandFromArgv(argv) + + if (command) { + cmuxArgs.push("--command", command) + } + + return [cmuxArgs] +} + +async function runCmuxCommandWithBun( + cmuxCommand: string, + args: string[], +): Promise { + const proc = Bun.spawn([cmuxCommand, ...args], { + stdout: "ignore", + stderr: "pipe", + }) + + try { + const exitCode = await withTimeout( + proc.exited, + CMUX_COMMAND_TIMEOUT_MS, + `cmux ${args[0]} timed out after ${CMUX_COMMAND_TIMEOUT_MS}ms`, + ) + const stderr = await new Response(proc.stderr).text() + return { + exitCode, + stderr: stderr.trim(), + } + } catch (error) { + if (error instanceof TimeoutError) { + try { + proc.kill() + } catch { + // Best-effort process cleanup + } + } + + throw error + } +} + +export async function openCmuxTerminalWithState( + cwd: string, + argv?: string[], + options?: { + env?: CmuxEnvironment + resolveExecutable?: ResolveExecutable + runCmuxCommand?: RunCmuxCommand + cmuxCommand?: string + }, +): Promise { + if (!cwd) { + return { + terminalResult: { success: false, error: "Working directory is required" }, + hasStateMutation: false, + } + } + + const env = options?.env ?? process.env + const cmuxCommand = options?.cmuxCommand ?? "cmux" + const resolveExecutable = options?.resolveExecutable ?? ((executable) => Bun.which(executable)) + if (!canUseCmuxWorkflow(env, resolveExecutable, cmuxCommand)) { + return { + terminalResult: { success: false, error: "cmux environment not available" }, + hasStateMutation: false, + } + } + + const context = detectCmuxContext(env) + const runCmuxCommand: RunCmuxCommand = + options?.runCmuxCommand ?? ((args) => runCmuxCommandWithBun(cmuxCommand, args)) + const commandSequence = buildCmuxCommandSequence(context, cwd, argv) + let hasStateMutation = false + + for (const args of commandSequence) { + let result: CmuxCommandResult + try { + result = await runCmuxCommand(args) + } catch (error) { + const hasIndeterminateMutation = error instanceof TimeoutError + return { + terminalResult: { + success: false, + error: `cmux ${args[0]} failed: ${error instanceof Error ? error.message : String(error)}`, + }, + hasStateMutation: hasStateMutation || hasIndeterminateMutation, + } + } + + if (result.exitCode !== 0) { + const stderr = result.stderr || "unknown cmux error" + return { + terminalResult: { + success: false, + error: `cmux ${args[0]} failed: ${stderr}`, + }, + hasStateMutation, + } + } + + hasStateMutation = true + } + + return { terminalResult: { success: true }, hasStateMutation } +} + +export async function openCmuxTerminal( + cwd: string, + argv?: string[], + options?: { + env?: CmuxEnvironment + resolveExecutable?: ResolveExecutable + runCmuxCommand?: RunCmuxCommand + cmuxCommand?: string + }, +): Promise { + const result = await openCmuxTerminalWithState(cwd, argv, options) + return result.terminalResult +} + +// ============================================================================= +// MACOS TERMINAL +// ============================================================================= + +/** + * Detect the current macOS terminal from environment variables. + * Prioritizes terminal-specific env vars over TERM_PROGRAM for reliability. + */ +function detectCurrentMacTerminal(): MacTerminal { + const env = macTerminalEnvSchema.parse(process.env) + + // Check specific env vars first (most reliable) + if (env.GHOSTTY_RESOURCES_DIR) return "ghostty" + if (env.ITERM_SESSION_ID) return "iterm" + if (env.KITTY_WINDOW_ID) return "kitty" + if (env.ALACRITTY_WINDOW_ID) return "alacritty" + if (env.__CFBundleIdentifier === "dev.warp.Warp-Stable") return "warp" + + // Fallback to TERM_PROGRAM + const termProgram = env.TERM_PROGRAM?.toLowerCase() + switch (termProgram) { + case "ghostty": + return "ghostty" + case "iterm.app": + return "iterm" + case "warpterm": + return "warp" + case "apple_terminal": + return "terminal" + } + + // Default to Terminal.app + return "terminal" +} + +/** + * Open terminal on macOS (Terminal.app, iTerm, Ghostty, etc.) + * Detects current terminal and uses appropriate method. + * + * @param cwd - Working directory for the terminal + * @param argv - Optional argv command to execute + * @returns Success status and optional error message + */ +export async function openMacOSTerminal(cwd: string, argv?: string[]): Promise { + // Guard: validate cwd + if (!cwd) { + return { success: false, error: "Working directory is required" } + } + + const escapedCwd = escapeBash(cwd) + const command = buildBashCommandFromArgv(argv) + const scriptContent = wrapWithSelfCleanup( + command ? `cd "${escapedCwd}" && ${command}\nexec bash` : `cd "${escapedCwd}"\nexec bash`, + ) + + const terminal = detectCurrentMacTerminal() + + // Track script path for detached spawns to clean up on error + let detachedScriptPath: string | null = null + + // Handle terminals based on whether they use detached spawns + try { + switch (terminal) { + // Ghostty uses inline command to avoid permission dialog - no temp script needed + case "ghostty": { + try { + const proc = Bun.spawn( + [ + "open", + "-na", + "Ghostty.app", + "--args", + `--working-directory=${cwd}`, + "-e", + "bash", + "-c", + command ? `cd "${escapedCwd}" && ${command}` : `cd "${escapedCwd}"`, + ], + { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }, + ) + proc.unref() + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + } + + // Detached terminals: write script directly - it self-deletes via trap + // DO NOT use withTempScript for these - the finally block would delete + // the script before the detached process reads it + case "kitty": { + // Try kitty @ remote control first (synchronous, can use withTempScript) + const remoteResult = await withTempScript(scriptContent, async (scriptPath) => { + const result = Bun.spawnSync([ + "kitty", + "@", + "launch", + "--type", + "tab", + "--cwd", + cwd, + "--", + "bash", + scriptPath, + ]) + return result.exitCode === 0 + }) + if (remoteResult) { + return { success: true } + } + + // Fallback: new window (detached) - write script directly + detachedScriptPath = path.join( + getTempDir(), + `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`, + ) + await Bun.write(detachedScriptPath, scriptContent) + await fs.chmod(detachedScriptPath, 0o755) + + const kittyProc = Bun.spawn( + ["kitty", "--directory", cwd, "-e", "bash", detachedScriptPath], + { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }, + ) + kittyProc.unref() + detachedScriptPath = null // Clear on success - script will self-clean + return { success: true } + } + + case "alacritty": { + // Detached spawn - write script directly + detachedScriptPath = path.join( + getTempDir(), + `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`, + ) + await Bun.write(detachedScriptPath, scriptContent) + await fs.chmod(detachedScriptPath, 0o755) + + const alacrittyProc = Bun.spawn( + ["alacritty", "--working-directory", cwd, "-e", "bash", detachedScriptPath], + { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }, + ) + alacrittyProc.unref() + detachedScriptPath = null // Clear on success - script will self-clean + return { success: true } + } + + case "warp": { + // Detached spawn - write script directly + detachedScriptPath = path.join( + getTempDir(), + `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`, + ) + await Bun.write(detachedScriptPath, scriptContent) + await fs.chmod(detachedScriptPath, 0o755) + + const warpProc = Bun.spawn(["open", "-b", "dev.warp.Warp-Stable", detachedScriptPath], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }) + warpProc.unref() + detachedScriptPath = null // Clear on success - script will self-clean + return { success: true } + } + + // iTerm uses AppleScript `write text` which returns before execution completes. + // Script must self-delete via trap β€” withTempScript would race. + case "iterm": { + detachedScriptPath = path.join( + getTempDir(), + `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`, + ) + await Bun.write(detachedScriptPath, scriptContent) + await fs.chmod(detachedScriptPath, 0o755) + + const escapedPath = escapeAppleScript(detachedScriptPath) + const appleScript = ` + tell application "iTerm" + if not (exists window 1) then + reopen + else + tell current window + create tab with default profile + end tell + end if + activate + tell first session of current tab of current window + write text "${escapedPath}" + end tell + end tell + ` + const result = Bun.spawnSync(["osascript", "-e", appleScript]) + if (result.exitCode !== 0) { + // Best-effort cleanup of orphaned script before returning + try { + await fs.rm(detachedScriptPath) + } catch { + // Best-effort cleanup + } + return { + success: false, + error: `iTerm AppleScript failed: ${result.stderr.toString()}`, + } + } + detachedScriptPath = null + return { success: true } + } + + default: { + // Terminal.app - waits for completion, safe to use withTempScript + return await withTempScript(scriptContent, async (scriptPath) => { + const proc = Bun.spawn(["open", "-a", "Terminal", scriptPath], { + stdio: ["ignore", "ignore", "pipe"], + }) + const exitCode = await proc.exited + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + return { success: false, error: `Failed to open Terminal: ${stderr}` } + } + return { success: true } + }) + } + } + } catch (error) { + // Clean up orphaned script on error (matches Linux/Windows behavior) + if (detachedScriptPath) { + try { + await fs.rm(detachedScriptPath) + } catch { + // Best-effort cleanup + } + } + return { + success: false, + error: `Failed to open terminal: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +// ============================================================================= +// LINUX TERMINAL +// ============================================================================= + +/** + * Detect the current Linux terminal from environment variables. + * Returns null if no terminal can be detected (use fallback chain). + */ +function detectCurrentLinuxTerminal(): LinuxTerminal | null { + const env = linuxTerminalEnvSchema.parse(process.env) + + // Check specific env vars first (most reliable) + if (env.KITTY_WINDOW_ID) return "kitty" + if (env.WEZTERM_PANE) return "wezterm" + if (env.ALACRITTY_WINDOW_ID) return "alacritty" + if (env.GHOSTTY_RESOURCES_DIR) return "ghostty" + if (env.GNOME_TERMINAL_SERVICE) return "gnome-terminal" + if (env.KONSOLE_VERSION) return "konsole" + + // TERM_PROGRAM fallback + const termProgram = env.TERM_PROGRAM?.toLowerCase() + if (termProgram === "warpterminal") return "warp" + if (termProgram === "foot") return "foot" + + return null +} + +/** + * Open terminal on Linux with desktop environment detection. + * Priority: current terminal > xdg-terminal-exec > x-terminal-emulator > modern > DE > xterm + * + * NOTE: All Linux terminal spawns are detached, so we write the script directly + * instead of using withTempScript. The script self-deletes via trap. + * + * @param cwd - Working directory for the terminal + * @param command - Optional command to execute + * @returns Success status and optional error message + */ +export async function openLinuxTerminal(cwd: string, argv?: string[]): Promise { + // Guard: validate cwd + if (!cwd) { + return { success: false, error: "Working directory is required" } + } + + const escapedCwd = escapeBash(cwd) + const command = buildBashCommandFromArgv(argv) + const scriptContent = wrapWithSelfCleanup( + command ? `cd "${escapedCwd}" && ${command}\nexec bash` : `cd "${escapedCwd}"\nexec bash`, + ) + + let scriptPath: string | null = null + let warpConfigPath: string | null = null + + const cleanupFile = async (filePath: string | null): Promise => { + if (!filePath) { + return + } + try { + await fs.rm(filePath) + } catch { + // Best-effort cleanup + } + } + + const ensureScriptPath = async (): Promise => { + if (scriptPath) { + return scriptPath + } + + // Write script directly - it self-deletes via trap + // DO NOT use withTempScript - all Linux spawns are detached + scriptPath = path.join( + getTempDir(), + `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`, + ) + await Bun.write(scriptPath, scriptContent) + await fs.chmod(scriptPath, 0o755) + return scriptPath + } + + try { + // Helper to try a terminal (all detached spawns) + const tryTerminal = async ( + name: string, + args: string[], + ): Promise<{ tried: boolean; success: boolean }> => { + const check = Bun.spawnSync(["which", name]) + if (check.exitCode !== 0) { + return { tried: false, success: false } + } + + try { + const proc = Bun.spawn(args, { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }) + proc.unref() + return { tried: true, success: true } + } catch { + return { tried: true, success: false } + } + } + + // 1. Check current terminal via env detection + const currentTerminal = detectCurrentLinuxTerminal() + if (currentTerminal) { + let result: { tried: boolean; success: boolean } + + switch (currentTerminal) { + case "kitty": { + const launchScriptPath = await ensureScriptPath() + // Try remote control first (synchronous, script still needed after) + const kittyRemote = Bun.spawnSync([ + "kitty", + "@", + "launch", + "--type", + "tab", + "--cwd", + cwd, + "--", + "bash", + launchScriptPath, + ]) + if (kittyRemote.exitCode === 0) { + return { success: true } + } + result = await tryTerminal("kitty", [ + "kitty", + "--directory", + cwd, + "-e", + "bash", + launchScriptPath, + ]) + break + } + case "wezterm": { + const launchScriptPath = await ensureScriptPath() + result = await tryTerminal("wezterm", [ + "wezterm", + "cli", + "spawn", + "--cwd", + cwd, + "--", + "bash", + launchScriptPath, + ]) + break + } + case "alacritty": { + const launchScriptPath = await ensureScriptPath() + result = await tryTerminal("alacritty", [ + "alacritty", + "--working-directory", + cwd, + "-e", + "bash", + launchScriptPath, + ]) + break + } + case "ghostty": { + const launchScriptPath = await ensureScriptPath() + result = await tryTerminal("ghostty", ["ghostty", "-e", "bash", launchScriptPath]) + break + } + case "warp": { + const configName = `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}` + const configDir = getWarpLaunchConfigDir() + const configPath = path.join(configDir, `${configName}.yaml`) + warpConfigPath = configPath + const configContent = buildWarpLaunchConfigYaml(configName, cwd, configPath, command) + + await fs.mkdir(configDir, { recursive: true }) + await Bun.write(configPath, configContent) + + result = await tryTerminal("warp-terminal", [ + "warp-terminal", + `warp://launch/${encodeURIComponent(configName)}`, + ]) + + if (!result.success) { + result = await tryTerminal("warp-terminal", [ + "warp-terminal", + `warp://launch/${encodeURIComponent(`${configName}.yaml`)}`, + ]) + } + + if (!result.success) { + await cleanupFile(warpConfigPath) + warpConfigPath = null + } + break + } + case "foot": { + const launchScriptPath = await ensureScriptPath() + result = await tryTerminal("foot", [ + "foot", + "--working-directory", + cwd, + "bash", + launchScriptPath, + ]) + break + } + case "gnome-terminal": { + const launchScriptPath = await ensureScriptPath() + result = await tryTerminal("gnome-terminal", [ + "gnome-terminal", + "--working-directory", + cwd, + "--", + "bash", + launchScriptPath, + ]) + break + } + case "konsole": { + const launchScriptPath = await ensureScriptPath() + result = await tryTerminal("konsole", [ + "konsole", + "--workdir", + cwd, + "-e", + "bash", + launchScriptPath, + ]) + break + } + default: + result = { tried: false, success: false } + } + + if (result.success) { + return { success: true } + } + } + + // 2. xdg-terminal-exec (modern XDG standard) + const launchScriptPath = await ensureScriptPath() + + const xdgResult = await tryTerminal("xdg-terminal-exec", [ + "xdg-terminal-exec", + "--", + "bash", + launchScriptPath, + ]) + if (xdgResult.success) return { success: true } + + // 3. x-terminal-emulator (Debian/Ubuntu) + const xteResult = await tryTerminal("x-terminal-emulator", [ + "x-terminal-emulator", + "-e", + "bash", + launchScriptPath, + ]) + if (xteResult.success) return { success: true } + + // 4. Modern terminals fallback + const modernTerminals: Array<{ name: string; args: string[] }> = [ + { name: "kitty", args: ["kitty", "--directory", cwd, "-e", "bash", launchScriptPath] }, + { + name: "alacritty", + args: ["alacritty", "--working-directory", cwd, "-e", "bash", launchScriptPath], + }, + { + name: "wezterm", + args: ["wezterm", "cli", "spawn", "--cwd", cwd, "--", "bash", launchScriptPath], + }, + { name: "ghostty", args: ["ghostty", "-e", "bash", launchScriptPath] }, + { name: "foot", args: ["foot", "--working-directory", cwd, "bash", launchScriptPath] }, + ] + + for (const { name, args } of modernTerminals) { + const result = await tryTerminal(name, args) + if (result.success) return { success: true } + } + + // 5. DE terminals fallback + const deTerminals: Array<{ name: string; args: string[] }> = [ + { + name: "gnome-terminal", + args: ["gnome-terminal", "--working-directory", cwd, "--", "bash", launchScriptPath], + }, + { name: "konsole", args: ["konsole", "--workdir", cwd, "-e", "bash", launchScriptPath] }, + { + name: "xfce4-terminal", + args: ["xfce4-terminal", "--working-directory", cwd, "-x", "bash", launchScriptPath], + }, + ] + + for (const { name, args } of deTerminals) { + const result = await tryTerminal(name, args) + if (result.success) return { success: true } + } + + // 6. Last resort: xterm + const xtermResult = await tryTerminal("xterm", ["xterm", "-e", "bash", launchScriptPath]) + if (xtermResult.success) return { success: true } + + // No terminal found - clean up orphaned temp files + await cleanupFile(scriptPath) + await cleanupFile(warpConfigPath) + scriptPath = null + warpConfigPath = null + return { success: false, error: "No terminal emulator found" } + } catch (error) { + await cleanupFile(scriptPath) + await cleanupFile(warpConfigPath) + return { + success: false, + error: `Failed to spawn terminal: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +// ============================================================================= +// WINDOWS TERMINAL +// ============================================================================= + +/** + * Open terminal on Windows (Windows Terminal or cmd). + * Tries Windows Terminal (wt.exe) first, falls back to cmd.exe. + * + * NOTE: All Windows terminal spawns are detached, so we write the script directly + * instead of using withTempScript. The script self-deletes via goto trick. + * + * @param cwd - Working directory for the terminal + * @param command - Optional command to execute + * @returns Success status and optional error message + */ +export async function openWindowsTerminal(cwd: string, argv?: string[]): Promise { + // Guard: validate cwd + if (!cwd) { + return { success: false, error: "Working directory is required" } + } + + const escapedCwd = escapeBatch(cwd) + const command = buildBatchCommandFromArgv(argv) + const scriptContent = wrapBatchWithSelfCleanup( + command ? `cd /d "${escapedCwd}"\r\n${command}\r\ncmd /k` : `cd /d "${escapedCwd}"\r\ncmd /k`, + ) + + // Write script directly - it self-deletes via goto trick + // DO NOT use withTempScript - all Windows spawns are detached + const scriptPath = path.join( + getTempDir(), + `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}.bat`, + ) + await Bun.write(scriptPath, scriptContent) + await fs.chmod(scriptPath, 0o755) + + try { + // Check for Windows Terminal + const wtCheck = Bun.spawnSync(["where", "wt"], { + stdout: "pipe", + stderr: "pipe", + }) + + if (wtCheck.exitCode === 0) { + try { + const proc = Bun.spawn(["wt.exe", "-d", cwd, "cmd", "/k", scriptPath], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }) + proc.unref() + return { success: true } + } catch { + // Fall through to cmd.exe + } + } + + // Fallback: cmd.exe + try { + const proc = Bun.spawn(["cmd", "/c", "start", "", scriptPath], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }) + proc.unref() + return { success: true } + } catch (error) { + // Failed to spawn - clean up orphaned script + try { + await fs.rm(scriptPath) + } catch { + // Best-effort cleanup + } + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + } catch (error) { + return { + success: false, + error: `Failed to spawn terminal: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +// ============================================================================= +// WSL TERMINAL +// ============================================================================= + +/** + * Open terminal in WSL via Windows Terminal interop. + * Falls back to bash in current terminal if wt.exe not available. + * + * NOTE: All WSL terminal spawns are detached, so we write the script directly + * instead of using withTempScript. The script self-deletes via trap. + */ +export async function openWSLTerminal(cwd: string, argv?: string[]): Promise { + // Guard: validate cwd + if (!cwd) { + return { success: false, error: "Working directory is required" } + } + + const escapedCwd = escapeBash(cwd) + const command = buildBashCommandFromArgv(argv) + const scriptContent = wrapWithSelfCleanup( + command ? `cd "${escapedCwd}" && ${command}\nexec bash` : `cd "${escapedCwd}"\nexec bash`, + ) + + // Write script directly - it self-deletes via trap + // DO NOT use withTempScript - all WSL spawns are detached + const scriptPath = path.join( + getTempDir(), + `worktree-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`, + ) + await Bun.write(scriptPath, scriptContent) + await fs.chmod(scriptPath, 0o755) + + try { + // Try wt.exe first (Windows Terminal via PATH interop) + const wtResult = Bun.spawnSync(["which", "wt.exe"]) + if (wtResult.exitCode === 0) { + try { + const proc = Bun.spawn(["wt.exe", "-d", cwd, "bash", scriptPath], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }) + proc.unref() + return { success: true } + } catch { + // Fall through to bash + } + } + + // Fallback: open in current terminal (new bash process) + try { + const proc = Bun.spawn(["bash", scriptPath], { + cwd, + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }) + proc.unref() + return { success: true } + } catch (error) { + // Failed to spawn - clean up orphaned script + try { + await fs.rm(scriptPath) + } catch { + // Best-effort cleanup + } + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } + } catch (error) { + return { + success: false, + error: `Failed to spawn terminal: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +// ============================================================================= +// UNIFIED TERMINAL OPENING +// ============================================================================= + +/** + * Open a terminal window on the current platform. + * Automatically detects the best terminal type and method. + * + * @param cwd - Working directory for the terminal + * @param command - Optional command to execute + * @param windowName - Optional window name (used for tmux) + * @returns Success status and optional error message + */ +export async function openTerminal( + cwd: string, + argv?: string[], + windowName?: string, + options?: { + detectTerminalType?: () => TerminalType + openCmuxTerminalWithState?: ( + cwd: string, + argv?: string[], + ) => Promise + openPlatformTerminal?: (cwd: string, argv?: string[]) => Promise + }, +): Promise { + const terminalType = options?.detectTerminalType?.() ?? detectTerminalType() + if (terminalType === "cmux") { + const cmuxResult = await (options?.openCmuxTerminalWithState ?? openCmuxTerminalWithState)( + cwd, + argv, + ) + if (cmuxResult.terminalResult.success) { + return cmuxResult.terminalResult + } + + if (!cmuxResult.hasStateMutation) { + return (options?.openPlatformTerminal ?? openPlatformTerminal)(cwd, argv) + } + + return cmuxResult.terminalResult + } + + return openTerminalByType(terminalType, cwd, argv, windowName) +} + +async function openPlatformTerminal(cwd: string, argv?: string[]): Promise { + const platformTerminalType = detectPlatformTerminalType() + return openTerminalByType(platformTerminalType, cwd, argv) +} + +async function openTerminalByType( + terminalType: Exclude, + cwd: string, + argv?: string[], + windowName?: string, +): Promise { + if (terminalType === "tmux") { + return openTmuxWindow({ + windowName: windowName || "worktree", + cwd, + argv, + }) + } + + switch (terminalType) { + case "macos": + return openMacOSTerminal(cwd, argv) + + case "windows": + // Check if we're in WSL + if (process.platform === "linux" && isInsideWSL()) { + return openWSLTerminal(cwd, argv) + } + return openWindowsTerminal(cwd, argv) + + case "linux-desktop": + return openLinuxTerminal(cwd, argv) + + default: + return { success: false, error: `Unsupported terminal type: ${terminalType}` } + } +} diff --git a/.opencode/skills/code-philosophy/SKILL.md b/.opencode/skills/code-philosophy/SKILL.md new file mode 100644 index 0000000..c9deb81 --- /dev/null +++ b/.opencode/skills/code-philosophy/SKILL.md @@ -0,0 +1,47 @@ +--- +name: code-philosophy +description: Internal logic and data flow philosophy (The 5 Laws of Elegant Defense). Understand deeply to ensure code guides data naturally and prevents errors. +--- + +# Internal Logic Philosophy: The 5 Laws of Elegant Defense + +**Role:** Principal Engineer for all **Internal Logic & Data Flow** β€” applies to backend, React components, hooks, state management, and any code where functionality matters. + +**Philosophy:** Elegant Simplicity β€” code should guide data so naturally that errors become impossible, keeping core logic flat, readable, and pristine. + +## The 5 Laws + +### 1. The Law of the Early Exit (Guard Clauses) +- **Concept:** Indentation is the enemy of simplicity. Deep nesting hides bugs. +- **Rule:** Handle edge cases, nulls, and errors at the very top of functions. +- **Practice:** Use `if (!valid) return; doWork();` instead of `if (valid) { doWork(); }`. + +### 2. Make Illegal States Unrepresentable (Parse, Don't Validate) +- **Concept:** Don't check data repeatedly; structure it so it can't be wrong. +- **Rule:** Parse inputs at the boundary. Once data enters internal logic, it must be in trusted, typed state. +- **Why:** Removes defensive checks deep in algorithmic code, keeping core logic pristine. + +### 3. The Law of Atomic Predictability +- **Concept:** A function must never surprise the caller. +- **Rule:** Functions should be "Pure" where possible. Same Input = Same Output. No hidden mutations. +- **Defense:** Avoid `void` functions that mutate global state. Return new data structures instead. + +### 4. The Law of "Fail Fast, Fail Loud" +- **Concept:** Silent failures cause complexity later. +- **Rule:** If a state is invalid, halt immediately with a descriptive error. Do not try to "patch" bad data. +- **Result:** Keeps logic simple by never accounting for "half-broken" states. + +### 5. The Law of Intentional Naming +- **Concept:** Comments are often a crutch for bad code. +- **Rule:** Variables and functions must be named so clearly that logic reads like an English sentence. +- **Defense:** `isUserEligible` is better than `check()`. The name itself guarantees the boolean logic. + +--- + +## Adherence Checklist +Before completing your task, verify: +- [ ] **Guard Clauses:** Are all edge cases handled at the top with early returns? +- [ ] **Parsed State:** Is data parsed into trusted types at the boundary? +- [ ] **Purity:** Are functions predictable and free of hidden mutations? +- [ ] **Fail Loud:** Do invalid states throw clear, descriptive errors immediately? +- [ ] **Readability:** Does the logic read like an English sentence? diff --git a/.opencode/skills/code-review/SKILL.md b/.opencode/skills/code-review/SKILL.md new file mode 100644 index 0000000..b0dd29e --- /dev/null +++ b/.opencode/skills/code-review/SKILL.md @@ -0,0 +1,105 @@ +--- +name: code-review +description: Comprehensive code review methodology with severity classification and confidence thresholds +--- + +# Code Review Philosophy + +## TL;DR +Systematic code review across 4 layers with severity classification. Only report findings with β‰₯80% confidence. Include file:line references for all issues. + +## When to Use This Skill +- Before reporting implementation completion +- When explicitly asked to review code +- When using the `/review` command +- As an independent audit after code changes + +## The 4 Review Layers + +### Layer 1: Correctness +- Logic errors and edge cases +- Error handling completeness +- Type safety and null checks +- Algorithm correctness +- Off-by-one errors + +### Layer 2: Security +- No hardcoded secrets or API keys +- Input validation and sanitization +- Injection vulnerability prevention (SQL, XSS, command) +- Authentication and authorization checks +- Sensitive data not logged +- OWASP Top 10 awareness + +### Layer 3: Performance +- No N+1 query patterns +- Appropriate caching strategies +- No unnecessary re-renders (React/frontend) +- Lazy loading where appropriate +- Memory leak prevention +- Algorithmic complexity concerns + +### Layer 4: Style & Maintainability +- Adherence to project conventions (check AGENTS.md) +- Code duplication (DRY violations) +- Complexity management (cyclomatic complexity) +- Documentation completeness +- Test coverage gaps + +## Severity Classification + +| Severity | Icon | Criteria | Action Required | +|----------|------|----------|-----------------| +| Critical | πŸ”΄ | Security vulnerabilities, crashes, data loss, corruption | Must fix before merge | +| Major | 🟠 | Bugs, performance issues, missing error handling | Should fix | +| Minor | 🟑 | Code smells, maintainability issues, test gaps | Nice to fix | +| Nitpick | 🟒 | Style preferences, naming suggestions, documentation | Optional | + +## Confidence Threshold + +**Only report findings with β‰₯80% confidence.** + +If uncertain about an issue: +- State the uncertainty explicitly: "Potential issue (70% confidence): ..." +- Suggest investigation rather than assert a problem +- Prefer false negatives over false positives (reduce noise) + +## Review Process + +1. **Initial Scan** - Identify all files in scope, understand the change +2. **Deep Analysis** - Apply all 4 layers systematically to each file +3. **Context Evaluation** - Consider surrounding code, project patterns, existing conventions +4. **Philosophy Check** - Verify against code-philosophy (5 Laws) if applicable +5. **Synthesize Findings** - Group by severity, deduplicate, prioritize + +## Output Format + +Structure your review as: + +1. **Files Reviewed** - List all files analyzed +2. **Overall Assessment** - APPROVE | REQUEST_CHANGES | NEEDS_DISCUSSION +3. **Summary** - 2-3 sentence overview +4. **Critical Issues** (πŸ”΄) - With file:line references +5. **Major Issues** (🟠) - With file:line references +6. **Minor Issues** (🟑) - With file:line references +7. **Positive Observations** (🟒) - What's done well (always include at least one) +8. **Philosophy Compliance** - Checklist results if applicable + +## What NOT to Do + +- Do NOT report low-confidence findings as definite issues +- Do NOT provide vague feedback without file:line references +- Do NOT skip any of the 4 layers +- Do NOT forget to note positive observations +- Do NOT modify any files during review +- Do NOT approve without completing the full review process + +## Adherence Checklist + +Before completing a review, verify: +- [ ] All 4 layers analyzed (Correctness, Security, Performance, Style) +- [ ] Severity assigned to each finding +- [ ] Confidence β‰₯80% for all reported issues (or uncertainty stated) +- [ ] File names and line numbers included for all findings +- [ ] Positive observations noted +- [ ] Output follows the standard format diff --git a/.opencode/skills/frontend-philosophy/SKILL.md b/.opencode/skills/frontend-philosophy/SKILL.md new file mode 100644 index 0000000..378a013 --- /dev/null +++ b/.opencode/skills/frontend-philosophy/SKILL.md @@ -0,0 +1,47 @@ +--- +name: frontend-philosophy +description: Visual & UI philosophy (The 5 Pillars of Intentional UI). Understand deeply to avoid "AI slop" and create distinctive, memorable interfaces. +--- + +# Frontend Design Philosophy: The 5 Pillars of Intentional UI + +**Role:** Design Director for all **Visual & Aesthetic decisions** β€” applies to styling, layout, colors, typography, animations, and UI composition. + +**Philosophy:** Distinctive, memorable, intentional design β€” avoiding generic "AI slop" aesthetics through bold, characterful choices that create immediate emotional impact. + +## The 5 Pillars + +### 1. Typography with Character +- **Concept:** Fonts set the entire tone. Generic fonts create generic, forgettable interfaces. +- **Rule:** Avoid Inter, Roboto, Arial, and system-ui defaults. Choose distinctive, characterful typefaces. +- **Practice:** Pair dramatic display fonts with refined, readable body fonts. + +### 2. Committed Color & Theme +- **Concept:** Timid palettes lack impact and feel algorithmically generated. +- **Rule:** Use bold, dominant colors with sharp accent contrasts. Avoid evenly-distributed rainbow gradients. +- **Practice:** Establish CSS variable systems early. Break away from the "purple gradient on white" AI clichΓ©. + +### 3. Purposeful Motion +- **Concept:** Animation should delight, not distract. Scattered micro-interactions create noise. +- **Rule:** One well-orchestrated animation beats a dozen minor transitions. Focus on high-impact moments. +- **Practice:** Use CSS animations for HTML, Motion library for React. Prioritize staggered reveals and surprsing hover states. + +### 4. Brave Spatial Composition +- **Concept:** Predictable layouts are forgettable. Safe spacing feels automated. +- **Rule:** Either generous negative space OR controlled density β€” not the middle ground. +- **Practice:** Embrace asymmetry, overlap, diagonal flow, and grid-breaking elements. + +### 5. Atmosphere & Depth +- **Concept:** Flat solid backgrounds lack presence and feel unfinished. +- **Rule:** Layer visual richness through gradient meshes, noise textures, geometric patterns, and transparencies. +- **Practice:** Add dramatic shadows, decorative borders, grain overlays. + +--- + +## Adherence Checklist +Before completing your task, verify: +- [ ] **Typography:** Did you avoid generic system fonts? +- [ ] **Color:** Are the color choices bold and intentional? +- [ ] **Motion:** Is there a primary, high-impact animation? +- [ ] **Space:** Does the layout feel designed rather than templated? +- [ ] **Depth:** Is there visual richness (textures, gradients, layering)? diff --git a/.opencode/skills/plan-protocol/SKILL.md b/.opencode/skills/plan-protocol/SKILL.md new file mode 100644 index 0000000..86fad49 --- /dev/null +++ b/.opencode/skills/plan-protocol/SKILL.md @@ -0,0 +1,259 @@ +--- +name: plan-protocol +description: Guidelines for creating and managing implementation plans with citations +--- + +# Plan Protocol + +> **Load this skill** when creating or updating implementation plans. + +## TL;DR Checklist + +When creating or updating a plan, ensure: + +- [ ] YAML frontmatter with `status`, `phase`, `updated` +- [ ] `## Goal` section (one sentence) +- [ ] `## Context & Decisions` table with citations (`ref:delegation-id`) +- [ ] Phases with status markers: `[COMPLETE]`, `[IN PROGRESS]`, `[PENDING]` +- [ ] Tasks with hierarchical numbering (1.1, 1.2, 2.1) +- [ ] Only ONE task marked `← CURRENT` +- [ ] Citations for all research-based decisions + +--- + +## When to Use + +1. Starting a multi-step implementation +2. After receiving a complex user request +3. When tracking progress across phases +4. After research that informs architectural decisions + +## When NOT to Use + +1. Simple one-off tasks β†’ use built-in todos instead +2. Pure research/exploration β†’ use delegations only +3. Quick fixes that don't need tracking +4. Single-file changes with no dependencies + +--- + +## Plan Format + +Use `plan_save` with this exact markdown format: + +```markdown +--- +status: STATUS +phase: PHASE_NUMBER +updated: YYYY-MM-DD +--- + +# Implementation Plan + +## Goal +ONE_SENTENCE_DESCRIBING_OUTCOME + +## Context & Decisions +| Decision | Rationale | Source | +|----------|-----------|--------| +| CHOICE | WHY | `ref:DELEGATION_ID` | + +## Phase 1: NAME [STATUS_MARKER] +- [x] 1.1 Completed task +- [x] 1.2 Another completed task β†’ `ref:DELEGATION_ID` + +## Phase 2: NAME [IN PROGRESS] +- [x] 2.1 Completed task +- [ ] **2.2 Current task** ← CURRENT +- [ ] 2.3 Pending task + +## Phase 3: NAME [PENDING] +- [ ] 3.1 Future task +- [ ] 3.2 Another future task + +## Notes +- YYYY-MM-DD: Observation or decision `ref:DELEGATION_ID` +``` + +### Frontmatter Fields + +| Field | Values | Description | +|-------|--------|-------------| +| `status` | `not-started`, `in-progress`, `complete`, `blocked` | Overall plan status | +| `phase` | Number (1, 2, 3...) | Current phase number | +| `updated` | `YYYY-MM-DD` | Last update date | + +### Phase Status Markers + +| Marker | Meaning | +|--------|---------| +| `[PENDING]` | Not yet started | +| `[IN PROGRESS]` | Currently being worked on | +| `[COMPLETE]` | Finished successfully | +| `[BLOCKED]` | Waiting on dependencies | + +--- + +## State Machine + +### Plan Lifecycle +``` +not-started β†’ in-progress β†’ complete + β†˜ blocked +``` + +### Phase Lifecycle +``` +[PENDING] β†’ [IN PROGRESS] β†’ [COMPLETE] + β†˜ [BLOCKED] +``` + +### Task Lifecycle +``` +[ ] unchecked β†’ [x] checked +``` + +### Critical Rules + +1. **Only ONE phase** may be `[IN PROGRESS]` at any time +2. **Only ONE task** may have `← CURRENT` marker at any time +3. **Move `← CURRENT`** immediately when starting a new task +4. **Mark tasks `[x]`** immediately after completing them + +--- + +## Citations & Delegations + +### Where Citations Come From + +Citations reference delegation research. The flow is: + +1. You delegate research: `delegate` to `researcher` or `explore` +2. Delegation completes with a readable ID (e.g., `swift-amber-falcon`) +3. You cite that research in the plan: `ref:swift-amber-falcon` + +### When to Cite + +| Situation | Action | +|-----------|--------| +| Architectural decision based on research | Add to Context & Decisions table | +| Task informed by research | Append `β†’ ref:id` to task line | +| Implementation detail from research | Inline citation in Notes | + +### How to Find Delegation IDs + +- Use `delegation_list()` to see all delegations +- Use `delegation_read("id")` to verify content before citing + +### ❌ NEVER + +- Make up delegation IDs +- Cite without actually reading the delegation +- Skip citations for research-based decisions + +--- + +## Examples + +### βœ… CORRECT: Well-formed plan + +```markdown +--- +status: in-progress +phase: 2 +updated: 2026-01-02 +--- + +# Implementation Plan + +## Goal +Add JWT authentication with refresh token support + +## Context & Decisions +| Decision | Rationale | Source | +|----------|-----------|--------| +| Use bcrypt (12 rounds) | Industry standard, balance of security/speed | `ref:swift-amber-falcon` | +| JWT with refresh tokens | Stateless auth, mobile-friendly | `ref:calm-jade-owl` | + +## Phase 1: Research [COMPLETE] +- [x] 1.1 Research auth patterns β†’ `ref:swift-amber-falcon` +- [x] 1.2 Evaluate token strategies β†’ `ref:calm-jade-owl` + +## Phase 2: Implementation [IN PROGRESS] +- [x] 2.1 Set up project structure +- [ ] **2.2 Add password hashing** ← CURRENT +- [ ] 2.3 Implement JWT generation + +## Phase 3: Testing [PENDING] +- [ ] 3.1 Write unit tests +- [ ] 3.2 Integration tests + +## Notes +- 2026-01-02: Chose bcrypt over argon2 for broader library support `ref:swift-amber-falcon` +``` + +### ❌ WRONG: Missing frontmatter + +```markdown +# Implementation Plan + +## Goal +Add authentication +``` + +**Error:** Plan must have YAML frontmatter with status, phase, updated. + +### ❌ WRONG: Multiple CURRENT markers + +```markdown +## Phase 2: Implementation [IN PROGRESS] +- [ ] **2.1 Task one** ← CURRENT +- [ ] **2.2 Task two** ← CURRENT +``` + +**Error:** Only one task may be marked CURRENT. + +### ❌ WRONG: Decision without citation + +```markdown +## Context & Decisions +| Decision | Rationale | Source | +|----------|-----------|--------| +| Use Redis | It's fast | - | +``` + +**Error:** Decisions must cite research with `ref:delegation-id`. + +### ❌ WRONG: Invalid phase status + +```markdown +## Phase 1: Research [DONE] +``` + +**Error:** Use `[COMPLETE]`, not `[DONE]`. Valid markers: `[PENDING]`, `[IN PROGRESS]`, `[COMPLETE]`, `[BLOCKED]`. + +--- + +## Troubleshooting + +| Error Message | Fix | +|---------------|-----| +| "Missing frontmatter" | Add `---\nstatus: in-progress\nphase: 1\nupdated: 2026-01-02\n---` at top | +| "Multiple CURRENT markers" | Remove `← CURRENT` from all but the active task | +| "Invalid citation format" | Use `ref:delegation-id` format (e.g., `ref:swift-amber-falcon`) | +| "Missing goal" | Add `## Goal` section with one-sentence description | +| "Empty phase" | Add at least one task to each phase | +| "Invalid phase status" | Use `[PENDING]`, `[IN PROGRESS]`, `[COMPLETE]`, or `[BLOCKED]` | + +--- + +## Before Saving Checklist + +Before calling `plan_save`, verify: + +- [ ] **Frontmatter:** Has status, phase, and updated date? +- [ ] **Goal:** Is there a clear, one-sentence goal? +- [ ] **Citations:** Are all research-based decisions cited with `ref:id`? +- [ ] **Single CURRENT:** Is exactly one task marked `← CURRENT`? +- [ ] **Valid markers:** Do all phases use valid status markers? +- [ ] **Hierarchical IDs:** Are tasks numbered correctly (1.1, 1.2, 2.1)? diff --git a/.opencode/skills/plan-review/SKILL.md b/.opencode/skills/plan-review/SKILL.md new file mode 100644 index 0000000..e75d908 --- /dev/null +++ b/.opencode/skills/plan-review/SKILL.md @@ -0,0 +1,152 @@ +--- +name: plan-review +description: Criteria for reviewing implementation plans against quality standards +--- + +# Plan Review + +> **Load this skill** when reviewing implementation plans (not code). + +## TL;DR +Systematic plan review focused on 3 quality categories: Citation Quality, Completeness, and Actionability. Structure is pre-validated by `plan_save`β€”focus on whether the plan provides actionable implementation guidance. + +## When to Use This Skill +- When reviewing implementation plans before execution +- When auditing plan quality after creation +- When verifying plans meet documentation standards +- As part of the plan validation workflow + +--- + +## Plan Review Checklist + +### 1. Structure (Pre-validated) + +> **Note:** Saved plans are structurally validated by `plan_save` before storage. +> Format compliance (YAML frontmatter, status markers, CURRENT marker, numbering) is guaranteed. +> Focus your review on the quality aspects below. + +### 2. Citation Quality + +| Requirement | Check | +|-------------|-------| +| Decisions reference sources | `ref:delegation-id` format used | +| No unsubstantiated claims | Architectural decisions cite research | +| Research phases show refs | Completed research tasks include citations | +| Citations are verifiable | IDs match actual delegation outputs | + +**Red Flags:** +- Decisions table with empty or `-` in Source column +- Claims like "industry standard" or "best practice" without citation +- Research tasks marked complete without `β†’ ref:id` + +### 3. Completeness + +| Requirement | Check | +|-------------|-------| +| Goal is specific | Measurable outcome, not vague intent | +| Phases are logical | Sequential, with clear progression | +| Edge cases considered | Error handling, failure modes addressed | +| Notes section present | Key decisions and observations documented | +| Context & Decisions table | Captures architectural choices with rationale | + +**Goal Quality Examples:** +- ❌ "Improve authentication" (vague) +- ❌ "Make it better" (unmeasurable) +- βœ… "Add JWT authentication with refresh token support" (specific) +- βœ… "Migrate user table to PostgreSQL with zero downtime" (measurable) + +### 4. Actionability + +| Requirement | Check | +|-------------|-------| +| Tasks are specific | Clear what file/component is affected | +| No ambiguous tasks | Avoids "investigate" or "figure out" without scope | +| Dependencies clear | Sequential tasks show logical order | +| Implementation path obvious | Developer can start without clarification | + +**Actionability Examples:** +- ❌ "Set up the backend" (too vague) +- ❌ "Make it work" (no implementation path) +- βœ… "Create `src/auth/jwt.ts` with sign/verify functions" (specific file) +- βœ… "Add bcrypt password hashing to `UserService.create()`" (clear scope) + +--- + +## Severity Classification + +| Severity | Icon | Criteria | Action Required | +|----------|------|----------|-----------------| +| Critical | πŸ”΄ | Missing citations for key decisions, no clear goal, unactionable tasks | Must fix before execution | +| Major | 🟠 | Vague tasks, incomplete phases, missing edge case handling | Should fix | +| Minor | 🟑 | Missing notes, unclear dependencies, incomplete rationale | Nice to fix | +| Nitpick | 🟒 | Style preferences, wording suggestions | Optional | + +--- + +## Output Format + +Structure your plan review as: + +```markdown +## Plan Review + +### Files Reviewed +- `PLAN.md` (or plan content from `plan_read`) + +### Overall Assessment +APPROVE | REQUEST_CHANGES | NEEDS_DISCUSSION + +### Summary +2-3 sentence overview of plan quality. + +### Issues + +#### πŸ”΄ Critical +- [Issue description with specific location] + +#### 🟠 Major +- [Issue description with specific location] + +#### 🟑 Minor +- [Issue description with specific location] + +#### 🟒 Nitpick +- [Suggestion] + +### Quality Assessment + +| Check | Status | +|-------|--------| +| Goal is specific and measurable | PASS / FAIL | +| Citations support key decisions | PASS / FAIL | +| Tasks are actionable | PASS / FAIL | +| Edge cases addressed | PASS / FAIL | + +### Positive Observations +- [What's done well - always include at least one] +``` + +--- + +## What NOT to Do + +- Do NOT re-validate formatβ€”`plan_save` handles structural validation +- Do NOT evaluate code quality (that's code-review's job) +- Do NOT execute or modify the plan during review +- Do NOT skip citation verification for decisions +- Do NOT accept vague goals or ambiguous tasks +- Do NOT forget to note positive observations + +--- + +## Adherence Checklist + +Before completing a plan review, verify: + +- [ ] All 3 quality categories analyzed (Citations, Completeness, Actionability) +- [ ] Severity assigned to each finding +- [ ] Specific locations noted for all issues +- [ ] Quality Assessment table completed +- [ ] Positive observations noted +- [ ] Output follows the standard format diff --git a/.opencode/tools/philosophy.md b/.opencode/tools/philosophy.md new file mode 100644 index 0000000..fe381d8 --- /dev/null +++ b/.opencode/tools/philosophy.md @@ -0,0 +1,16 @@ +## Code Philosophy - MANDATORY + +Before writing or modifying any code, you MUST: + +1. **Select the relevant philosophy** based on your task: + - Working on UI/frontend? β†’ Load **`frontend-philosophy`** (The 5 Pillars of Intentional UI) + - Working on backend/logic? β†’ Load **`code-philosophy`** (The 5 Laws of Elegant Defense) + - Working on both? β†’ Load both + +2. **Load the skill** using the `skill` tool BEFORE implementation + +3. **Verify your implementation** against the philosophy checklist BEFORE completing + +4. **Refactor if needed** - if code violates any principle, fix it before proceeding + +This is NOT optional. These philosophies define how code must be written in this project. diff --git a/package.json b/package.json index e03808e..5c340f7 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,13 @@ { - "name": "@anthropic-ai/claude-code", - "version": "2.1.88", - "bin": { - "claude": "cli.js" - }, - "engines": { - "node": ">=18.0.0" - }, + "name": "opencode-plugins", + "private": true, "type": "module", - "author": "Anthropic ", - "license": "SEE LICENSE IN README.md", - "description": "Use Claude, Anthropic's AI assistant, right from your terminal. Claude can understand your codebase, edit files, run terminal commands, and handle entire workflows for you.", - "homepage": "https://github.com/anthropics/claude-code", - "bugs": { - "url": "https://github.com/anthropics/claude-code/issues" - }, - "scripts": { - "prepare": "node -e \"if (!process.env.AUTHORIZED) { console.error('ERROR: Direct publishing is not allowed.\\nPlease see the release workflow documentation to publish this package.'); process.exit(1); }\"" - }, "dependencies": {}, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.34.2", - "@img/sharp-darwin-x64": "^0.34.2", - "@img/sharp-linux-arm": "^0.34.2", - "@img/sharp-linux-arm64": "^0.34.2", - "@img/sharp-linux-x64": "^0.34.2", - "@img/sharp-linuxmusl-arm64": "^0.34.2", - "@img/sharp-linuxmusl-x64": "^0.34.2", - "@img/sharp-win32-arm64": "^0.34.2", - "@img/sharp-win32-x64": "^0.34.2" + "devDependencies": { + "unique-names-generator": "4.7.1", + "zod": "4.3.5", + "node-notifier": "10.0.1", + "detect-terminal": "2.0.0", + "jsonc-parser": "3.3.1" } }