From b7a7ecf4498878af5cb0af828d4b7b8b9cf09fd9 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Fri, 15 May 2026 09:25:13 +0200 Subject: [PATCH] refactor: kilo compat for v1.14.34 --- .github/ISSUE_TEMPLATE/bug-report.yml | 1 - .github/ISSUE_TEMPLATE/feature-request.yml | 1 - .github/ISSUE_TEMPLATE/question.yml | 1 - .github/workflows/daily-issues-recap.yml | 170 - .github/workflows/daily-pr-recap.yml | 173 - .github/workflows/publish.yml | 219 +- .opencode-version | 1 + .opencode/agent/triage.md | 127 +- .opencode/command/changelog.md | 5 +- .opencode/tool/github-triage.ts | 80 +- README.md | 2 +- bun.lock | 36 +- nix/hashes.json | 8 +- package.json | 21 +- packages/core/package.json | 6 +- packages/core/src/filesystem.ts | 8 + packages/core/src/flag/flag.ts | 15 +- packages/core/src/util/log.ts | 2 + .../core/test/filesystem/filesystem.test.ts | 28 + packages/extensions/zed/extension.toml | 12 +- .../migration.sql | 17 + .../snapshot.json | 1409 ++ .../snapshot.json | 36 +- .../20260501142318_next_venus/migration.sql | 2 + .../20260501142318_next_venus/snapshot.json | 1439 ++ packages/opencode/package.json | 42 +- packages/opencode/script/build.ts | 1 + packages/opencode/script/httpapi-exercise.ts | 2014 ++ packages/opencode/src/acp/agent.ts | 38 +- packages/opencode/src/bus/bus-event.ts | 2 + packages/opencode/src/bus/global.ts | 14 +- packages/opencode/src/bus/index.ts | 25 +- packages/opencode/src/cli/bootstrap.ts | 9 +- packages/opencode/src/cli/cmd/account.ts | 47 +- packages/opencode/src/cli/cmd/acp.ts | 99 +- packages/opencode/src/cli/cmd/agent.ts | 334 +- packages/opencode/src/cli/cmd/cmd.ts | 2 +- packages/opencode/src/cli/cmd/debug/agent.ts | 189 +- packages/opencode/src/cli/cmd/debug/config.ts | 17 +- packages/opencode/src/cli/cmd/debug/file.ts | 64 +- packages/opencode/src/cli/cmd/debug/index.ts | 55 +- packages/opencode/src/cli/cmd/debug/lsp.ts | 55 +- .../opencode/src/cli/cmd/debug/ripgrep.ts | 90 +- packages/opencode/src/cli/cmd/debug/skill.ts | 22 +- .../opencode/src/cli/cmd/debug/snapshot.ts | 37 +- packages/opencode/src/cli/cmd/export.ts | 138 +- packages/opencode/src/cli/cmd/generate.ts | 22 +- packages/opencode/src/cli/cmd/github.ts | 406 +- packages/opencode/src/cli/cmd/import.ts | 245 +- packages/opencode/src/cli/cmd/mcp.ts | 1031 +- packages/opencode/src/cli/cmd/plug.ts | 39 +- packages/opencode/src/cli/cmd/pr.ts | 201 +- packages/opencode/src/cli/cmd/providers.ts | 591 +- packages/opencode/src/cli/cmd/run.ts | 674 +- packages/opencode/src/cli/cmd/serve.ts | 19 +- packages/opencode/src/cli/cmd/session.ts | 76 +- packages/opencode/src/cli/cmd/stats.ts | 266 +- packages/opencode/src/cli/cmd/tui/app.tsx | 45 +- packages/opencode/src/cli/cmd/tui/attach.ts | 13 +- .../cli/cmd/tui/component/prompt/index.tsx | 12 +- .../opencode/src/cli/cmd/tui/config/tui.ts | 122 +- .../src/cli/cmd/tui/context/sync-v2.tsx | 307 + .../tui/feature-plugins/system/session-v2.tsx | 1087 + .../src/cli/cmd/tui/plugin/internal.ts | 3 + .../src/cli/cmd/tui/plugin/runtime.ts | 6 +- .../src/cli/cmd/tui/routes/session/index.tsx | 9 +- .../cli/cmd/tui/routes/session/permission.tsx | 3 +- packages/opencode/src/cli/cmd/tui/worker.ts | 32 +- packages/opencode/src/cli/cmd/web.ts | 19 +- packages/opencode/src/cli/effect-cmd.ts | 75 +- packages/opencode/src/cli/effect/prompt.ts | 24 +- packages/opencode/src/config/config.ts | 56 +- packages/opencode/src/config/paths.ts | 10 - packages/opencode/src/effect/app-runtime.ts | 25 +- packages/opencode/src/file/watcher.ts | 6 +- packages/opencode/src/git/index.ts | 106 +- packages/opencode/src/permission/index.ts | 1 - packages/opencode/src/plugin/codex.ts | 9 +- packages/opencode/src/plugin/index.ts | 7 +- .../opencode/src/project/bootstrap-service.ts | 9 + packages/opencode/src/project/bootstrap.ts | 32 +- .../opencode/src/project/instance-layer.ts | 11 + .../opencode/src/project/instance-runtime.ts | 16 + .../opencode/src/project/instance-store.ts | 42 +- packages/opencode/src/project/instance.ts | 9 - packages/opencode/src/project/project.ts | 29 +- packages/opencode/src/project/vcs.ts | 227 +- .../opencode/src/project/with-instance.ts | 12 + packages/opencode/src/provider/provider.ts | 22 +- packages/opencode/src/pty/ticket.ts | 68 + packages/opencode/src/server/auth.ts | 48 + packages/opencode/src/server/cors.ts | 20 + packages/opencode/src/server/error.ts | 3 + packages/opencode/src/server/fence.ts | 74 +- .../opencode/src/server/global-lifecycle.ts | 37 + .../src/server/httpapi-server.node.ts | 34 + .../opencode/src/server/httpapi-server.ts | 9 + packages/opencode/src/server/middleware.ts | 5 + packages/opencode/src/server/proxy.ts | 2 +- packages/opencode/src/server/routes/global.ts | 27 +- .../src/server/routes/instance/config.ts | 38 +- .../src/server/routes/instance/event.ts | 2 + .../src/server/routes/instance/httpapi/api.ts | 2 + .../server/routes/instance/httpapi/event.ts | 4 +- .../routes/instance/httpapi/groups/global.ts | 1 + .../routes/instance/httpapi/groups/pty.ts | 15 +- .../routes/instance/httpapi/groups/v2.ts | 14 + .../instance/httpapi/groups/v2/message.ts | 69 + .../instance/httpapi/groups/v2/session.ts | 140 + .../instance/httpapi/handlers/config.ts | 2 +- .../instance/httpapi/handlers/global.ts | 20 +- .../routes/instance/httpapi/handlers/pty.ts | 58 +- .../instance/httpapi/handlers/session.ts | 6 +- .../routes/instance/httpapi/handlers/tui.ts | 2 +- .../routes/instance/httpapi/handlers/v2.ts | 6 + .../instance/httpapi/handlers/v2/message.ts | 60 + .../instance/httpapi/handlers/v2/session.ts | 115 + .../httpapi/middleware/authorization.ts | 131 +- .../instance/httpapi/middleware/error.ts | 58 + .../httpapi/middleware/instance-context.ts | 10 +- .../instance/httpapi/middleware/proxy.ts | 28 + .../httpapi/middleware/workspace-routing.ts | 4 +- .../server/routes/instance/httpapi/server.ts | 28 +- .../instance/httpapi/websocket-tracker.ts | 57 + .../src/server/routes/instance/index.ts | 137 +- .../src/server/routes/instance/middleware.ts | 6 +- .../src/server/routes/instance/project.ts | 5 +- .../src/server/routes/instance/pty.ts | 86 +- .../src/server/routes/instance/tui.ts | 30 +- packages/opencode/src/server/routes/ui.ts | 96 +- packages/opencode/src/server/server.ts | 184 +- packages/opencode/src/server/shared/fence.ts | 74 + .../opencode/src/server/shared/pty-ticket.ts | 15 + .../opencode/src/server/shared/public-ui.ts | 12 + .../opencode/src/server/shared/tui-control.ts | 28 + packages/opencode/src/server/shared/ui.ts | 105 + .../src/server/shared/workspace-routing.ts | 36 + packages/opencode/src/server/workspace.ts | 45 +- packages/opencode/src/session/compaction.ts | 24 +- packages/opencode/src/session/processor.ts | 154 +- .../opencode/src/session/projectors-next.ts | 207 + packages/opencode/src/session/projectors.ts | 5 +- packages/opencode/src/session/prompt.ts | 122 +- packages/opencode/src/session/session.sql.ts | 25 +- packages/opencode/src/session/session.ts | 33 + packages/opencode/src/storage/db.ts | 1 + packages/opencode/src/sync/index.ts | 8 +- packages/opencode/src/tool/registry.ts | 8 +- .../opencode/src/tool/{bash.ts => shell.ts} | 108 +- packages/opencode/src/tool/shell/id.ts | 19 + packages/opencode/src/tool/shell/prompt.ts | 295 + .../src/tool/{bash.txt => shell/shell.txt} | 60 +- packages/opencode/src/util/effect-zod.ts | 2 +- packages/opencode/src/util/error.ts | 28 +- packages/opencode/src/util/lazy.ts | 2 + packages/opencode/src/util/timeout.ts | 4 +- packages/opencode/src/v2/event.ts | 53 + packages/opencode/src/v2/schema.ts | 10 + .../opencode/src/v2/session-entry-stepper.ts | 261 - packages/opencode/src/v2/session-entry.ts | 220 - packages/opencode/src/v2/session-event.ts | 698 +- .../src/v2/session-message-updater.ts | 420 + packages/opencode/src/v2/session-message.ts | 178 + packages/opencode/src/v2/session-prompt.ts | 36 + packages/opencode/src/v2/session.ts | 305 +- packages/opencode/src/v2/tool-output.ts | 18 + packages/opencode/src/worktree/index.ts | 38 +- packages/opencode/test/AGENTS.md | 33 +- .../test/acp/event-subscription.test.ts | 22 +- packages/opencode/test/agent/agent.test.ts | 77 +- .../agent/plugin-agent-regression.test.ts | 28 + packages/opencode/test/bus/bus-effect.test.ts | 167 +- .../opencode/test/bus/bus-integration.test.ts | 3 +- packages/opencode/test/bus/bus.test.ts | 3 +- .../test/cli/effect-cmd-instance-als.test.ts | 48 + .../opencode/test/cli/tui/use-event.test.tsx | 2 + packages/opencode/test/config/config.test.ts | 158 +- packages/opencode/test/config/tui.test.ts | 51 +- .../test/control-plane/workspace.test.ts | 3 +- .../test/effect/instance-state.test.ts | 7 +- packages/opencode/test/file/fsmonitor.test.ts | 5 +- packages/opencode/test/file/index.test.ts | 109 +- .../opencode/test/file/path-traversal.test.ts | 23 +- packages/opencode/test/file/watcher.test.ts | 5 +- .../test/fixture/agent-plugin.constants.ts | 6 + .../opencode/test/fixture/agent-plugin.ts | 12 + packages/opencode/test/fixture/config.ts | 23 + packages/opencode/test/fixture/fixture.ts | 68 +- packages/opencode/test/git/git.test.ts | 47 + packages/opencode/test/lib/effect.ts | 60 +- packages/opencode/test/lsp/client.test.ts | 25 +- packages/opencode/test/mcp/headers.test.ts | 7 +- packages/opencode/test/mcp/lifecycle.test.ts | 7 +- .../test/mcp/oauth-auto-connect.test.ts | 9 +- .../opencode/test/mcp/oauth-browser.test.ts | 7 +- .../opencode/test/permission-task.test.ts | 13 +- .../opencode/test/permission/next.test.ts | 17 +- .../test/plugin/auth-override.test.ts | 73 +- .../test/plugin/loader-shared.test.ts | 40 +- packages/opencode/test/preload.ts | 3 +- .../instance-bootstrap-regression.test.ts | 86 + .../opencode/test/project/instance.test.ts | 130 +- .../opencode/test/project/project.test.ts | 2 + packages/opencode/test/project/vcs.test.ts | 34 +- .../opencode/test/project/worktree.test.ts | 21 +- .../test/provider/amazon-bedrock.test.ts | 61 +- .../opencode/test/provider/gitlab-duo.test.ts | 27 +- .../opencode/test/provider/provider.test.ts | 347 +- .../test/pty/pty-output-isolation.test.ts | 7 +- .../opencode/test/pty/pty-session.test.ts | 5 +- packages/opencode/test/pty/pty-shell.test.ts | 7 +- packages/opencode/test/pty/ticket.test.ts | 57 + .../opencode/test/question/question.test.ts | 519 +- packages/opencode/test/server/auth.test.ts | 59 + packages/opencode/test/server/global-bus.ts | 34 + .../test/server/global-session-list.test.ts | 13 +- .../test/server/httpapi-authorization.test.ts | 57 +- .../test/server/httpapi-bridge.test.ts | 15 +- .../test/server/httpapi-config.test.ts | 20 +- .../test/server/httpapi-event.test.ts | 15 +- .../test/server/httpapi-experimental.test.ts | 24 +- .../server/httpapi-instance-context.test.ts | 30 +- .../server/httpapi-instance.legacy.test.ts | 32 +- .../test/server/httpapi-listen.test.ts | 319 + .../test/server/httpapi-mcp-oauth.test.ts | 5 +- .../opencode/test/server/httpapi-mcp.test.ts | 5 +- .../test/server/httpapi-parity.test.ts | 128 + .../test/server/httpapi-provider.test.ts | 5 +- .../opencode/test/server/httpapi-sdk.test.ts | 3 +- .../test/server/httpapi-session.test.ts | 49 +- .../opencode/test/server/httpapi-sync.test.ts | 3 +- .../opencode/test/server/httpapi-tui.test.ts | 15 +- .../opencode/test/server/httpapi-ui.test.ts | 106 +- .../test/server/session-actions.test.ts | 3 +- .../opencode/test/server/session-list.test.ts | 41 +- .../test/server/session-messages.test.ts | 9 +- .../test/server/session-select.test.ts | 7 +- .../test/server/workspace-routing.test.ts | 6 +- .../server/worktree-endpoint-repro.test.ts | 148 + .../opencode/test/session/compaction.test.ts | 52 +- .../opencode/test/session/instruction.test.ts | 17 +- packages/opencode/test/session/llm.test.ts | 17 +- .../opencode/test/session/message-v2.test.ts | 6 +- .../test/session/messages-pagination.test.ts | 85 +- packages/opencode/test/session/prompt.test.ts | 44 + .../session/session-entry-stepper.test.ts | 916 - .../opencode/test/session/session.test.ts | 9 +- .../structured-output-integration.test.ts | 3 +- .../opencode/test/snapshot/snapshot.test.ts | 115 +- packages/opencode/test/sync/index.test.ts | 2 +- .../opencode/test/tool/apply_patch.test.ts | 49 +- packages/opencode/test/tool/edit.test.ts | 37 +- .../test/tool/external-directory.test.ts | 15 +- packages/opencode/test/tool/glob.test.ts | 78 +- packages/opencode/test/tool/grep.test.ts | 103 +- .../opencode/test/tool/parameters.test.ts | 16 +- packages/opencode/test/tool/question.test.ts | 85 +- packages/opencode/test/tool/read.test.ts | 26 +- packages/opencode/test/tool/registry.test.ts | 290 +- .../test/tool/{bash.test.ts => shell.test.ts} | 199 +- .../opencode/test/tool/truncation.test.ts | 7 +- packages/opencode/test/tool/webfetch.test.ts | 7 +- packages/opencode/test/tool/write.test.ts | 316 +- packages/opencode/test/util/error.test.ts | 13 + .../test/v2/session-message-updater.test.ts | 203 + packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/sdk/js/script/build.ts | 8 +- packages/sdk/js/src/v2/client.ts | 19 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 3649 +-- packages/sdk/js/src/v2/gen/types.gen.ts | 5955 +++-- packages/sdk/openapi.json | 19150 ++++++++++------ packages/storybook/package.json | 2 +- packages/ui/package.json | 10 +- script/raw-changelog.ts | 40 +- sdks/vscode/package.json | 2 +- specs/v2/session-concepts-gap.md | 131 + 278 files changed, 34416 insertions(+), 19348 deletions(-) delete mode 100644 .github/workflows/daily-issues-recap.yml delete mode 100644 .github/workflows/daily-pr-recap.yml create mode 100644 .opencode-version create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/migration.sql create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json create mode 100644 packages/opencode/migration/20260501142318_next_venus/migration.sql create mode 100644 packages/opencode/migration/20260501142318_next_venus/snapshot.json create mode 100644 packages/opencode/script/httpapi-exercise.ts create mode 100644 packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx create mode 100644 packages/opencode/src/project/bootstrap-service.ts create mode 100644 packages/opencode/src/project/instance-layer.ts create mode 100644 packages/opencode/src/project/instance-runtime.ts create mode 100644 packages/opencode/src/project/with-instance.ts create mode 100644 packages/opencode/src/pty/ticket.ts create mode 100644 packages/opencode/src/server/auth.ts create mode 100644 packages/opencode/src/server/global-lifecycle.ts create mode 100644 packages/opencode/src/server/httpapi-server.node.ts create mode 100644 packages/opencode/src/server/httpapi-server.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts create mode 100644 packages/opencode/src/server/shared/fence.ts create mode 100644 packages/opencode/src/server/shared/pty-ticket.ts create mode 100644 packages/opencode/src/server/shared/public-ui.ts create mode 100644 packages/opencode/src/server/shared/tui-control.ts create mode 100644 packages/opencode/src/server/shared/ui.ts create mode 100644 packages/opencode/src/server/shared/workspace-routing.ts create mode 100644 packages/opencode/src/session/projectors-next.ts rename packages/opencode/src/tool/{bash.ts => shell.ts} (81%) create mode 100644 packages/opencode/src/tool/shell/id.ts create mode 100644 packages/opencode/src/tool/shell/prompt.ts rename packages/opencode/src/tool/{bash.txt => shell/shell.txt} (59%) create mode 100644 packages/opencode/src/v2/event.ts create mode 100644 packages/opencode/src/v2/schema.ts delete mode 100644 packages/opencode/src/v2/session-entry-stepper.ts delete mode 100644 packages/opencode/src/v2/session-entry.ts create mode 100644 packages/opencode/src/v2/session-message-updater.ts create mode 100644 packages/opencode/src/v2/session-message.ts create mode 100644 packages/opencode/src/v2/session-prompt.ts create mode 100644 packages/opencode/src/v2/tool-output.ts create mode 100644 packages/opencode/test/agent/plugin-agent-regression.test.ts create mode 100644 packages/opencode/test/cli/effect-cmd-instance-als.test.ts create mode 100644 packages/opencode/test/fixture/agent-plugin.constants.ts create mode 100644 packages/opencode/test/fixture/agent-plugin.ts create mode 100644 packages/opencode/test/fixture/config.ts create mode 100644 packages/opencode/test/project/instance-bootstrap-regression.test.ts create mode 100644 packages/opencode/test/pty/ticket.test.ts create mode 100644 packages/opencode/test/server/auth.test.ts create mode 100644 packages/opencode/test/server/global-bus.ts create mode 100644 packages/opencode/test/server/httpapi-listen.test.ts create mode 100644 packages/opencode/test/server/httpapi-parity.test.ts create mode 100644 packages/opencode/test/server/worktree-endpoint-repro.test.ts delete mode 100644 packages/opencode/test/session/session-entry-stepper.test.ts rename packages/opencode/test/tool/{bash.test.ts => shell.test.ts} (88%) create mode 100644 packages/opencode/test/v2/session-message-updater.test.ts create mode 100644 specs/v2/session-concepts-gap.md diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index fe1ec8409b..96234eb25d 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,6 +1,5 @@ name: Bug report description: Report an issue that should be fixed -labels: ["bug"] body: - type: textarea id: description diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 92e6c47570..42f1d3c51a 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,6 +1,5 @@ name: 🚀 Feature Request description: Suggest an idea, feature, or enhancement -labels: [discussion] title: "[FEATURE]:" body: diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 2310bfcc86..8930ba693c 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,6 +1,5 @@ name: Question description: Ask a question -labels: ["question"] body: - type: textarea id: question diff --git a/.github/workflows/daily-issues-recap.yml b/.github/workflows/daily-issues-recap.yml deleted file mode 100644 index 31cf08233b..0000000000 --- a/.github/workflows/daily-issues-recap.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: daily-issues-recap - -on: - schedule: - # Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving) - - cron: "0 23 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - daily-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily issues recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - # Get today's date range - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather today's issues - Search for all OPEN issues created today (${TODAY}) using: - gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 - - IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) issues only. - - STEP 2: Analyze and categorize - For each issue created today, categorize it: - - **Severity Assessment:** - - CRITICAL: Crashes, data loss, security issues, blocks major functionality - - HIGH: Significant bugs affecting many users, important features broken - - MEDIUM: Bugs with workarounds, minor features broken - - LOW: Minor issues, cosmetic, nice-to-haves - - **Activity Assessment:** - - Note issues with high comment counts or engagement - - Note issues from repeat reporters (check if author has filed before) - - STEP 3: Cross-reference with existing issues - For issues that seem like feature requests or recurring bugs: - - Search for similar older issues to identify patterns - - Note if this is a frequently requested feature - - Identify any issues that are duplicates of long-standing requests - - STEP 4: Generate the recap - Create a structured recap with these sections: - - ===DISCORD_START=== - **Daily Issues Recap - ${TODAY}** - - **Summary Stats** - - Total issues opened today: [count] - - By category: [bugs/features/questions] - - **Critical/High Priority Issues** - [List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers] - - **Most Active/Discussed** - [Issues with significant engagement or from active community members] - - **Trending Topics** - [Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature'] - - **Duplicates & Related** - [Issues that relate to existing open issues] - ===DISCORD_END=== - - STEP 5: Format for Discord - Format the recap as a Discord-compatible message: - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report - - Use hyperlinked issue numbers with suppressed embeds: [#1234]() - - Group related issues on single lines where possible - - Add emoji sparingly for critical items only - - HARD LIMIT: Keep under 1800 characters total - - Skip sections that have nothing notable (e.g., if no critical issues, omit that section) - - Prioritize signal over completeness - only surface what matters - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt - - echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily recap to Discord" diff --git a/.github/workflows/daily-pr-recap.yml b/.github/workflows/daily-pr-recap.yml deleted file mode 100644 index 2f0f023cfd..0000000000 --- a/.github/workflows/daily-pr-recap.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: daily-pr-recap - -on: - schedule: - # Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving) - - cron: "0 22 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - pr-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily PR recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh pr*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather PR data - Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}): - - # Open PRs created today - gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - # Open PRs with activity today (updated today) - gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) contributions only. - - - - STEP 2: For high-activity PRs, check comment counts - For promising PRs, run: - gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length' - - IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts: - - copilot-pull-request-reviewer - - github-actions - - STEP 3: Identify what matters (ONLY from today's PRs) - - **Bug Fixes From Today:** - - PRs with 'fix' or 'bug' in title created/updated today - - Small bug fixes (< 100 lines changed) that are easy to review - - Bug fixes from community contributors - - **High Activity Today:** - - PRs with significant human comments today (excluding bots listed above) - - PRs with back-and-forth discussion today - - **Quick Wins:** - - Small PRs (< 50 lines) that are approved or nearly approved - - PRs that just need a final review - - STEP 4: Generate the recap - Create a structured recap: - - ===DISCORD_START=== - **Daily PR Recap - ${TODAY}** - - **New PRs Today** - [PRs opened today - group by type: bug fixes, features, etc.] - - **Active PRs Today** - [PRs with activity/updates today - significant discussion] - - **Quick Wins** - [Small PRs ready to merge] - ===DISCORD_END=== - - STEP 5: Format for Discord - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - surface what we might miss - - Use hyperlinked PR numbers with suppressed embeds: [#1234]() - - Include PR author: [#1234]() (@author) - - For bug fixes, add brief description of what it fixes - - Show line count for quick wins: \"(+15/-3 lines)\" - - HARD LIMIT: Keep under 1800 characters total - - Skip empty sections - - Focus on PRs that need human eyes - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt - - echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/pr_recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/pr_recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily PR recap to Discord" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9981edad7f..4614226a8a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -209,182 +209,6 @@ jobs: packages/opencode/dist/opencode-windows-x64 packages/opencode/dist/opencode-windows-x64-baseline - build-tauri: - needs: - - build-cli - - version - continue-on-error: false - env: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - strategy: - fail-fast: false - matrix: - settings: - - host: macos-latest - target: x86_64-apple-darwin - - host: macos-latest - target: aarch64-apple-darwin - # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain - - host: windows-2025 - target: aarch64-pc-windows-msvc - - host: blacksmith-4vcpu-windows-2025 - target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2404 - target: x86_64-unknown-linux-gnu - - host: blacksmith-8vcpu-ubuntu-2404-arm - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.settings.host }} - steps: - - uses: actions/checkout@v3 - with: - fetch-tags: true - - - uses: apple-actions/import-codesign-certs@v2 - if: ${{ runner.os == 'macOS' }} - with: - keychain: build - p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} - p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - - name: Verify Certificate - if: ${{ runner.os == 'macOS' }} - run: | - CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - echo "Certificate imported." - - - name: Setup Apple API Key - if: ${{ runner.os == 'macOS' }} - run: | - echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8 - - - uses: ./.github/actions/setup-bun - - - name: Azure login - if: runner.os == 'Windows' - uses: azure/login@v2 - with: - client-id: ${{ env.AZURE_CLIENT_ID }} - tenant-id: ${{ env.AZURE_TENANT_ID }} - subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Cache apt packages - if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 - with: - path: ~/apt-cache - key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.settings.target }}-apt- - - - name: install dependencies (ubuntu only) - if: contains(matrix.settings.host, 'ubuntu') - run: | - mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache - sudo apt-get update - sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - sudo chmod -R a+rw ~/apt-cache - - - name: install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.settings.target }} - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: packages/desktop/src-tauri - shared-key: ${{ matrix.settings.target }} - - - name: Prepare - run: | - cd packages/desktop - bun ./scripts/prepare.ts - env: - OPENCODE_VERSION: ${{ needs.version.outputs.version }} - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} - RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - - - name: Resolve tauri portable SHA - if: contains(matrix.settings.host, 'ubuntu') - run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV" - - # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - name: Install tauri-cli from portable appimage branch - uses: taiki-e/cache-cargo-install-action@v3 - if: contains(matrix.settings.host, 'ubuntu') - with: - tool: tauri-cli - git: https://github.com/tauri-apps/tauri - # branch: feat/truly-portable-appimage - rev: ${{ env.TAURI_PORTABLE_SHA }} - - - name: Show tauri-cli version - if: contains(matrix.settings.host, 'ubuntu') - run: cargo tauri --version - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Build and upload artifacts - uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a - timeout-minutes: 60 - with: - projectPath: packages/desktop - uploadWorkflowArtifacts: true - tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }} - args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose - updaterJsonPreferNsis: true - releaseId: ${{ needs.version.outputs.release }} - tagName: ${{ needs.version.outputs.tag }} - releaseDraft: true - releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] - repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }} - releaseCommitish: ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 - - - name: Verify signed Windows desktop artifacts - if: runner.os == 'Windows' - shell: pwsh - run: | - $files = @( - "${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe" - ) - $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName - - foreach ($file in $files) { - $sig = Get-AuthenticodeSignature $file - if ($sig.Status -ne "Valid") { - throw "Invalid signature for ${file}: $($sig.Status)" - } - } - build-electron: needs: - build-cli @@ -524,6 +348,30 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + - name: Create and upload macOS .app.tar.gz + if: runner.os == 'macOS' && needs.version.outputs.release + working-directory: packages/desktop-electron/dist + env: + GH_TOKEN: ${{ steps.committer.outputs.token }} + run: | + if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then + APP_DIR="mac" + OUT_NAME="opencode-desktop-mac-x64.app.tar.gz" + elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then + APP_DIR="mac-arm64" + OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz" + else + echo "Unknown macOS target: ${{ matrix.settings.target }}" + exit 1 + fi + APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "No .app bundle found in $APP_DIR" + exit 1 + fi + tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")" + gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}" + - name: Verify signed Windows Electron artifacts if: runner.os == 'Windows' shell: pwsh @@ -542,7 +390,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: opencode-electron-${{ matrix.settings.target }} + name: opencode-desktop-${{ matrix.settings.target }} path: packages/desktop-electron/dist/* - uses: actions/upload-artifact@v4 @@ -556,7 +404,6 @@ jobs: - version - build-cli - sign-cli-windows - - build-tauri - build-electron if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -583,13 +430,6 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - uses: actions/download-artifact@v4 with: name: opencode-cli @@ -611,6 +451,13 @@ jobs: pattern: latest-yml-* path: /tmp/latest-yml + - name: Setup git committer + id: committer + uses: ./.github/actions/setup-git-committer + with: + opencode-app-id: ${{ vars.OPENCODE_APP_ID }} + opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + - name: Cache apt packages (AUR) uses: actions/cache@v4 with: @@ -639,3 +486,5 @@ jobs: GH_REPO: ${{ needs.version.outputs.repo }} NPM_CONFIG_PROVENANCE: false LATEST_YML_DIR: /tmp/latest-yml + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} diff --git a/.opencode-version b/.opencode-version new file mode 100644 index 0000000000..92f061fdfe --- /dev/null +++ b/.opencode-version @@ -0,0 +1 @@ +v1.14.34 diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index a77b92737b..03df339cb8 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/minimax-m2.5 +model: opencode/gpt-5.4-nano color: "#44BA81" tools: "*": false @@ -14,127 +14,30 @@ Use your github-triage tool to triage issues. This file is the source of truth for ownership/routing rules. -## Labels +Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team. -### windows +Do not add labels to issues. Only assign an owner. -Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows. +When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows. -- Use if they mention WSL too +## Teams -#### perf +### TUI -Performance-related issues: +Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. -- Slow performance -- High RAM usage -- High CPU usage +### Desktop / Web -**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness. +Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. -#### desktop +### Core -Desktop app issues: +Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features. -- `opencode web` command -- The desktop app itself +### Inference -**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues. +OpenCode Zen, OpenCode Go, and billing issues. -#### nix +### Windows -**Only** add if the issue explicitly mentions nix. - -If the issue does not mention nix, do not add nix. - -If the issue mentions nix, assign to `rekram1-node`. - -#### zen - -**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". - -If the issue doesn't have "zen" or "opencode black" in it then don't add zen label - -#### core - -Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`. - -Examples: - -- LSP server behavior -- Harness behavior (agent + tools) -- Feature requests for server behavior -- Agent context construction -- API endpoints -- Provider integration issues -- New, broken, or poor-quality models - -#### acp - -If the issue mentions acp support, assign acp label. - -#### docs - -Add if the issue requests better documentation or docs updates. - -#### opentui - -TUI issues potentially caused by our underlying TUI library: - -- Keybindings not working -- Scroll speed issues (too fast/slow/laggy) -- Screen flickering -- Crashes with opentui in the log - -**Do not** add for general TUI bugs. - -When assigning to people here are the following rules: - -Desktop / Web: -Use for desktop-labeled issues only. - -- adamdotdevin -- iamdavidhill -- Brendonovich -- nexxeln - -Zen: -ONLY assign if the issue will have the "zen" label. - -- fwang -- MrMushrooooom - -TUI (`packages/opencode/src/cli/cmd/tui/...`): - -- thdxr for TUI UX/UI product decisions and interaction flow -- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks -- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues - -Core (`packages/opencode/...`, excluding TUI subtree): - -- thdxr for sqlite/snapshot/memory bugs and larger architectural core features -- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable) -- rekram1-node for harness issues, provider issues, and other bug-squashing - -For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable. - -Docs: - -- R44VC0RP - -Windows: - -- Hona (assign any issue that mentions Windows or is likely Windows-specific) - -Determinism rules: - -- If title + body does not contain "zen", do not add the "zen" label -- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix" -- If title + body mentions nix/nixos, assign to `rekram1-node` -- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner - -In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random. - -ACP: - -- rekram1-node (assign any acp issues to rekram1-node) +Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index 4cd30a704a..b28d963d00 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -18,9 +18,12 @@ Do not use `git log` or author metadata when deciding attribution. Rules: -- Write the final file with sections in this order: +- Write the final file with release sections in this order: `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` - Only include sections that have at least one notable entry +- Within each release section, keep bug fixes grouped under `### Bugfixes` +- Keep other notable entries under `### Improvements` when a section has bug fixes too +- Omit empty subsections - Keep one bullet per commit you keep - Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing - Start each bullet with a capital letter diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 56886808a4..35db44641e 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,16 +1,14 @@ /// import { tool } from "@opencode-ai/plugin" + const TEAM = { - desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], - zen: ["fwang", "MrMushrooooom"], - tui: ["kommander", "rekram1-node", "simonklee"], - core: ["kitlangton", "rekram1-node", "jlongster"], - docs: ["R44VC0RP"], + tui: ["kommander", "simonklee"], + desktop_web: ["Hona", "Brendonovich"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], + inference: ["fwang", "MrMushrooooom"], windows: ["Hona"], } as const -const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] - function pick(items: readonly T[]) { return items[Math.floor(Math.random() * items.length)]! } @@ -38,79 +36,25 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { } export default tool({ - description: `Use this tool to assign and/or label a GitHub issue. + description: `Use this tool to assign a GitHub issue. -Choose labels and assignee using the current triage policy and ownership rules. -Pick the most fitting labels for the issue and assign one owner. - -If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`, +Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, args: { - assignee: tool.schema - .enum(ASSIGNEES as [string, ...string[]]) - .describe("The username of the assignee") - .default("rekram1-node"), - labels: tool.schema - .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) - .describe("The labels(s) to add to the issue") - .default([]), + team: tool.schema + .enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]) + .describe("The owning team"), }, async execute(args) { const issue = getIssueNumber() const owner = "anomalyco" const repo = "opencode" - - const results: string[] = [] - let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))] - const web = labels.includes("web") - const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase() - const zen = /\bzen\b/.test(text) || text.includes("opencode black") - const nix = /\bnix(os)?\b/.test(text) - - if (labels.includes("nix") && !nix) { - labels = labels.filter((x) => x !== "nix") - results.push("Dropped label: nix (issue does not mention nix)") - } - - const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee - - if (labels.includes("zen") && !zen) { - throw new Error("Only add the zen label when issue title/body contains 'zen'") - } - - if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) { - throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln") - } - - if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) { - throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom") - } - - if (assignee === "Hona" && !labels.includes("windows")) { - throw new Error("Only windows issues should be assigned to Hona") - } - - if (assignee === "R44VC0RP" && !labels.includes("docs")) { - throw new Error("Only docs issues should be assigned to R44VC0RP") - } - - if (assignee === "kommander" && !labels.includes("opentui")) { - throw new Error("Only opentui issues should be assigned to kommander") - } + const assignee = pick(TEAM[args.team]) await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { method: "POST", body: JSON.stringify({ assignees: [assignee] }), }) - results.push(`Assigned @${assignee} to issue #${issue}`) - if (labels.length > 0) { - await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { - method: "POST", - body: JSON.stringify({ labels }), - }) - results.push(`Added labels: ${labels.join(", ")}`) - } - - return results.join("\n") + return `Assigned @${assignee} from ${args.team} to issue #${issue}` }, }) diff --git a/README.md b/README.md index 79ccf8b349..3ebfb1627c 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ - 100% open source - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important. -- Out-of-the-box LSP support +- Built-in opt-in LSP support - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients. diff --git a/bun.lock b/bun.lock index 12677ea976..3cf2d9ce99 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.33", + "version": "1.14.34", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.33", + "version": "1.14.34", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -715,7 +715,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -3078,7 +3078,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], diff --git a/nix/hashes.json b/nix/hashes.json index bea97a0cb3..84c3b13043 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=", - "aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=", - "aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=", - "x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U=" + "x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=", + "aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=", + "aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=", + "x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI=" } } diff --git a/package.json b/package.json index 7f7fc7c5e9..66ceb63a03 100644 --- a/package.json +++ b/package.json @@ -7,18 +7,16 @@ "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev:desktop": "bun --cwd packages/desktop-electron dev", - "dev:web": "bun --cwd packages/app dev", - "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", - "postinstall": "bun run --cwd packages/opencode fix-node-pty", + "postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts", "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", "test": "echo 'do not run tests from root' && exit 1", - "extension": "bun --cwd packages/kilo-vscode script/launch.ts" + "extension": "bun --cwd packages/kilo-vscode script/launch.ts", + "dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup" }, "workspaces": { "packages": [ @@ -49,10 +47,10 @@ "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.12.12", @@ -76,8 +74,6 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", - "@sentry/solid": "10.36.0", - "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.12", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" @@ -141,15 +137,14 @@ "smol-toml": ">=1.6.1", "fastify": ">=5.8.3", "diff": "8.0.4", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "happy-dom": ">=20.8.9" }, "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" }, - "version": "7.2.42", + "version": "7.2.52", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 5477a12e98..32ffc2df5b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.42", + "version": "7.2.52", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -40,10 +40,10 @@ "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", + "rotating-file-stream": "3.2.9", "semver": "^7.6.3", "xdg-basedir": "5.1.0", - "zod": "catalog:", - "rotating-file-stream": "3.2.9" + "zod": "catalog:" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 44346be8f9..8a1cc3a08f 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -24,6 +24,7 @@ export namespace AppFileSystem { readonly isDir: (path: string) => Effect.Effect readonly isFile: (path: string) => Effect.Effect readonly existsSafe: (path: string) => Effect.Effect + readonly readFileStringSafe: (path: string) => Effect.Effect readonly readJson: (path: string) => Effect.Effect readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect readonly ensureDir: (path: string) => Effect.Effect @@ -47,6 +48,12 @@ export namespace AppFileSystem { return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) }) + const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) { + return yield* fs + .readFileString(path) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) + }) + const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) return info?.type === "Directory" @@ -163,6 +170,7 @@ export namespace AppFileSystem { return Service.of({ ...fs, existsSafe, + readFileStringSafe, isDir, isFile, readDirectoryEntries, diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index f167b06c95..f7cf6a4754 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,10 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels that default to the new effect-httpapi server backend. The legacy +// hono backend remains the default for stable (`prod`/`latest`) installs. +const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -81,8 +86,16 @@ export const Flag = { KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"), KILO_WORKSPACE_ID: process.env["KILO_WORKSPACE_ID"], - KILO_EXPERIMENTAL_HTTPAPI: truthy("KILO_EXPERIMENTAL_HTTPAPI"), + // Defaults to true on dev/beta/local channels so internal users exercise the + // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay + // on the legacy hono backend until the rollout is complete. An explicit env + // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for + // stable users and an escape hatch for dev/beta users. + KILO_EXPERIMENTAL_HTTPAPI: + truthy("KILO_EXPERIMENTAL_HTTPAPI") || + (!falsy("KILO_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), KILO_EXPERIMENTAL_WORKSPACES: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_WORKSPACES"), + KILO_EXPERIMENTAL_EVENT_SYSTEM: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EVENT_SYSTEM"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index a61c15f7a7..e1962aed4c 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -1,3 +1,5 @@ +export * as Log from "./log" + import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index b77f4e356f..1d9405333d 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -65,6 +65,34 @@ describe("AppFileSystem", () => { ) }) + describe("readFileStringSafe", () => { + it( + "returns file contents when file exists", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* filesys.writeFileString(file, "hello") + + const result = yield* fs.readFileStringSafe(file) + expect(result).toBe("hello") + }), + ) + + it( + "returns undefined for missing file (NotFound)", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + + const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt")) + expect(result).toBeUndefined() + }), + ) + }) + describe("readJson / writeJson", () => { it( "round-trips JSON data", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 93f06c8ee2..8940b9be4c 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "1.14.33" +version = "1.14.34" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.34/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.34/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.34/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.34/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.33/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.34/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql new file mode 100644 index 0000000000..d5efe5f9e8 --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE `session_message` ( + `id` text PRIMARY KEY, + `session_id` text NOT NULL, + `type` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + `data` text NOT NULL, + CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint +CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint +CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint +CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint +DROP TABLE `session_entry`; \ No newline at end of file diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json new file mode 100644 index 0000000000..a237b4156e --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -0,0 +1,1409 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "61f807f9-6398-4067-be05-804acc2561bc", + "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json index d79324fedf..740ba0e254 100644 --- a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -2,7 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", - "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], + "prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"], "ddl": [ { "name": "account_state", @@ -37,7 +37,7 @@ "entityType": "tables" }, { - "name": "session_entry", + "name": "session_message", "entityType": "tables" }, { @@ -598,7 +598,7 @@ "generated": null, "name": "id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -608,7 +608,7 @@ "generated": null, "name": "session_id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -618,7 +618,7 @@ "generated": null, "name": "type", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -628,7 +628,7 @@ "generated": null, "name": "time_created", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -638,7 +638,7 @@ "generated": null, "name": "time_updated", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -648,7 +648,7 @@ "generated": null, "name": "data", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -1112,9 +1112,9 @@ "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, - "name": "fk_session_entry_session_id_session_id_fk", + "name": "fk_session_message_session_id_session_id_fk", "entityType": "fks", - "table": "session_entry" + "table": "session_message" }, { "columns": ["project_id"], @@ -1226,8 +1226,8 @@ { "columns": ["id"], "nameExplicit": false, - "name": "session_entry_pk", - "table": "session_entry", + "name": "session_message_pk", + "table": "session_message", "entityType": "pks" }, { @@ -1322,9 +1322,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_idx", + "name": "session_message_session_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1340,9 +1340,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_type_idx", + "name": "session_message_session_type_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1354,9 +1354,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_time_created_idx", + "name": "session_message_time_created_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ diff --git a/packages/opencode/migration/20260501142318_next_venus/migration.sql b/packages/opencode/migration/20260501142318_next_venus/migration.sql new file mode 100644 index 0000000000..e0ffe7823c --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint +ALTER TABLE `session` ADD `model` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/opencode/migration/20260501142318_next_venus/snapshot.json new file mode 100644 index 0000000000..1eb0cf0b07 --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/snapshot.json @@ -0,0 +1,1439 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67", + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2a38544fce..714285df06 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.42", + "version": "7.2.52", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -38,10 +38,16 @@ "bun": "./src/server/adapter.bun.ts", "node": "./src/server/adapter.node.ts", "default": "./src/server/adapter.bun.ts" + }, + "#httpapi-server": { + "bun": "./src/server/httpapi-server.node.ts", + "node": "./src/server/httpapi-server.node.ts", + "default": "./src/server/httpapi-server.node.ts" } }, "devDependencies": { "@babel/core": "7.28.4", + "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -72,7 +78,6 @@ "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", "zod-to-json-schema": "3.24.5", - "@effect/language-service": "0.84.2", "@types/npmcli__arborist": "6.3.3" }, "dependencies": { @@ -103,19 +108,26 @@ "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", + "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/node-server": "1.19.13", "@hono/node-ws": "1.3.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", + "@kilocode/kilo-gateway": "workspace:*", + "@kilocode/kilo-indexing": "workspace:*", + "@kilocode/kilo-telemetry": "workspace:*", + "@kilocode/plugin": "workspace:*", + "@kilocode/sdk": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", + "@morphllm/morphsdk": "0.2.166", + "@npmcli/arborist": "9.4.0", + "@npmcli/config": "10.8.1", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", - "@kilocode/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", - "@kilocode/sdk": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -134,6 +146,7 @@ "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", "bun-pty": "0.4.8", + "chardet": "2.1.1", "chokidar": "4.0.3", "cli-sound": "1.1.3", "clipboardy": "4.0.0", @@ -149,6 +162,7 @@ "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "catalog:", + "iconv-lite": "0.7.2", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", @@ -161,11 +175,15 @@ "opentui-spinner": "catalog:", "partial-json": "0.1.7", "remeda": "catalog:", + "ripgrep": "0.3.1", + "rotating-file-stream": "3.2.9", "semver": "^7.6.3", + "simple-git": "3.36.0", "solid-js": "catalog:", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", + "tree-sitter-wasms": "^0.1.12", "turndown": "7.2.0", "ulid": "catalog:", "venice-ai-sdk-provider": "2.0.1", @@ -175,21 +193,7 @@ "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5", - "@gitlab/gitlab-ai-provider": "3.6.0", - "@kilocode/kilo-gateway": "workspace:*", - "@kilocode/kilo-indexing": "workspace:*", - "@kilocode/kilo-telemetry": "workspace:*", - "@morphllm/morphsdk": "0.2.166", - "@npmcli/arborist": "9.4.0", - "@npmcli/config": "10.8.1", - "chardet": "2.1.1", - "iconv-lite": "0.7.2", - "ripgrep": "0.3.1", - "rotating-file-stream": "3.2.9", - "simple-git": "3.35.2", - "stream-chat": "9.38.0", - "tree-sitter-wasms": "^0.1.12" + "zod-to-json-schema": "3.24.5" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index def2f9b5d0..36016cf02e 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -61,6 +61,7 @@ const createEmbeddedWebUIBundle = async () => { await $`bun run --cwd ${appDir} build` const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) .map((file) => file.replaceAll("\\", "/")) + .filter((file) => !file.endsWith(".map")) .sort() const imports = files.map((file, i) => { const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/") diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts new file mode 100644 index 0000000000..2ccb09193b --- /dev/null +++ b/packages/opencode/script/httpapi-exercise.ts @@ -0,0 +1,2014 @@ +/** + * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. + * + * The goal is not to be a normal unit test file. This is a route-coverage and parity + * harness we can run while deleting Hono: every public route should eventually have a + * small scenario that proves the Effect route decodes requests, uses the right instance + * context, mutates storage when expected, and returns a compatible response shape. + * + * The script intentionally isolates `KILO_DB` before importing modules that touch + * storage. Scenarios may create/delete sessions and reset the database after each run, + * so this must never point at a developer's real session database. + * + * DSL shape: + * - `http.get/post/...` starts a scenario for one OpenAPI route key. + * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. + * - `.at(...)` builds the request from that typed state. + * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. + * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts + * so destructive routes compare equivalent fresh setups instead of sharing one DB. + */ +import { Cause, ConfigProvider, Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { TestLLMServer } from "../test/lib/llm-server" +import type { Config } from "../src/config/config" +import { MessageID, PartID, type SessionID } from "../src/session/schema" +import { ModelID, ProviderID } from "../src/provider/schema" +import type { MessageV2 } from "../src/session/message-v2" +import type { Worktree } from "../src/worktree" +import type { Project } from "../src/project/project" +import path from "path" + +const preserveExerciseGlobalRoot = !!process.env.KILO_HTTPAPI_EXERCISE_GLOBAL +const exerciseGlobalRoot = + process.env.KILO_HTTPAPI_EXERCISE_GLOBAL ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") +process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") +process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") +process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") +process.env.KILO_DISABLE_SHARE = "true" +const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") +const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") + +const preserveExerciseDatabase = !!process.env.KILO_HTTPAPI_EXERCISE_DB +const exerciseDatabasePath = + process.env.KILO_HTTPAPI_EXERCISE_DB ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +process.env.KILO_DB = exerciseDatabasePath +Flag.KILO_DB = exerciseDatabasePath + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const +const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const +const color = { + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + reset: "\x1b[0m", +} + +type Method = (typeof Methods)[number] +type OpenApiMethod = (typeof OpenApiMethods)[number] +type Mode = "effect" | "parity" | "coverage" +type Backend = "effect" | "legacy" +type Comparison = "none" | "status" | "json" +type CaptureMode = "full" | "stream" +type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } +type OpenApiSpec = { paths?: Record>> } +type JsonObject = Record + +type Options = { + mode: Mode + include: string | undefined + failOnMissing: boolean + failOnSkip: boolean +} + +type RequestSpec = { + path: string + headers?: Record + body?: unknown +} + +type CallResult = { + status: number + contentType: string + body: unknown + text: string +} + +type BackendApp = { + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +/** Effect-native helpers available while setting up and asserting a scenario. */ +type ScenarioContext = { + directory: string | undefined + headers: (extra?: Record) => Record + file: (name: string, content: string) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + sessionGet: (sessionID: SessionID) => Effect.Effect + project: () => Effect.Effect + message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect + messages: (sessionID: SessionID) => Effect.Effect + todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect + worktree: (input?: { name?: string }) => Effect.Effect + worktreeRemove: (directory: string) => Effect.Effect + llmText: (value: string) => Effect.Effect + llmWait: (count: number) => Effect.Effect + tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect +} + +/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ +type SeededContext = ScenarioContext & { + state: S +} + +type Scenario = ActiveScenario | TodoScenario +type ActiveScenario = { + kind: "active" + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: ScenarioContext, state: unknown) => RequestSpec + expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect + compare: Comparison + capture: CaptureMode + mutates: boolean + reset: boolean +} + +/** Internal builder state stays generic until `.json(...)` erases it into `ActiveScenario`. */ +type BuilderState = { + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: SeededContext) => RequestSpec + capture: CaptureMode + mutates: boolean + reset: boolean +} +type TodoScenario = { + kind: "todo" + method: Method + path: string + name: string + reason: string +} +type Result = + | { status: "pass"; scenario: ActiveScenario } + | { status: "fail"; scenario: ActiveScenario; message: string } + | { status: "skip"; scenario: TodoScenario } + +type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } +type TodoInfo = { content: string; status: string; priority: string } +type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } + +const original = { + KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI, + KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD, + KILO_SERVER_USERNAME: Flag.KILO_SERVER_USERNAME, +} + +type Runtime = { + PublicApi: (typeof import("../src/server/routes/instance/httpapi/public"))["PublicApi"] + ExperimentalHttpApiServer: (typeof import("../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] + Server: (typeof import("../src/server/server"))["Server"] + AppLayer: (typeof import("../src/effect/app-runtime"))["AppLayer"] + InstanceRef: (typeof import("../src/effect/instance-ref"))["InstanceRef"] + Instance: (typeof import("../src/project/instance"))["Instance"] + InstanceStore: (typeof import("../src/project/instance-store"))["InstanceStore"] + Session: (typeof import("../src/session/session"))["Session"] + Todo: (typeof import("../src/session/todo"))["Todo"] + Worktree: (typeof import("../src/worktree"))["Worktree"] + Project: (typeof import("../src/project/project"))["Project"] + Tui: typeof import("../src/server/shared/tui-control") + disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] + tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] + resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] +} + +let runtimePromise: Promise | undefined + +function runtime() { + return (runtimePromise ??= (async () => { + const publicApi = await import("../src/server/routes/instance/httpapi/public") + const httpApiServer = await import("../src/server/routes/instance/httpapi/server") + const server = await import("../src/server/server") + const appRuntime = await import("../src/effect/app-runtime") + const instanceRef = await import("../src/effect/instance-ref") + const instance = await import("../src/project/instance") + const instanceStore = await import("../src/project/instance-store") + const session = await import("../src/session/session") + const todo = await import("../src/session/todo") + const worktree = await import("../src/worktree") + const project = await import("../src/project/project") + const tui = await import("../src/server/shared/tui-control") + const fixture = await import("../test/fixture/fixture") + const db = await import("../test/fixture/db") + return { + PublicApi: publicApi.PublicApi, + ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, + Server: server.Server, + AppLayer: appRuntime.AppLayer, + InstanceRef: instanceRef.InstanceRef, + Instance: instance.Instance, + InstanceStore: instanceStore.InstanceStore, + Session: session.Session, + Todo: todo.Todo, + Worktree: worktree.Worktree, + Project: project.Project, + Tui: tui, + disposeAllInstances: fixture.disposeAllInstances, + tmpdir: fixture.tmpdir, + resetDatabase: db.resetDatabase, + } + })()) +} + +class ScenarioBuilder { + private readonly state: BuilderState + + constructor(method: Method, path: string, name: string) { + this.state = { + method, + path, + name, + project: { git: true }, + seed: () => Effect.succeed(undefined as S), + request: (ctx) => ({ path, headers: ctx.headers() }), + capture: "full", + mutates: false, + reset: true, + } + } + + global() { + return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) + } + + inProject(project: ProjectOptions = { git: true }) { + return this.clone({ project }) + } + + withLlm() { + return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + } + + at(request: BuilderState["request"]) { + return this.clone({ request }) + } + + mutating() { + return this.clone({ mutates: true }) + } + + preserveDatabase() { + return this.clone({ reset: false }) + } + + stream() { + return this.clone({ capture: "stream" }) + } + + /** Assert a non-JSON or shape-only response. */ + ok(status = 200, compare: Comparison = "status") { + return this.done(compare, (_ctx, result) => + Effect.sync(() => { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + }), + ) + } + + status( + status = 200, + inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, + compare: Comparison = "status", + ) { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (inspect) yield* inspect(ctx, result) + }), + ) + } + + /** Assert JSON status/content-type plus an optional synchronous body check. */ + json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { + return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) + } + + /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ + jsonEffect( + status = 200, + inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, + compare: Comparison = "json", + ) { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (!looksJson(result)) + throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (inspect) yield* inspect(result.body, ctx) + }), + ) + } + + private clone(next: Partial>) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, next) + return builder + } + + /** + * Seed typed state before the HTTP request. The returned value becomes `ctx.state` + * for `.at(...)` and assertions, giving stateful route tests type-safe setup. + */ + seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, { seed }) + return builder + } + + private done( + compare: Comparison, + expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, + ): ActiveScenario { + const state = this.state + return { + kind: "active", + method: state.method, + path: state.path, + name: state.name, + project: state.project, + seed: state.seed, + request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), + expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), + compare, + capture: state.capture, + mutates: state.mutates, + reset: state.reset, + } + } +} + +const http = { + get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), + post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), + put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), + patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), + delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), +} + +const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ + kind: "todo", + method, + path, + name, + reason, +}) + +function route(template: string, params: Record) { + return Object.entries(params).reduce( + (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), + template, + ) +} + +const scenarios: Scenario[] = [ + http + .get("/global/health", "global.health") + .global() + .json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), + http + .get("/global/event", "global.event") + .global() + .stream() + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status", + ), + http.get("/global/config", "global.config.get").global().json(), + http + .patch("/global/config", "global.config.update") + .global() + .seeded(() => + Effect.promise(() => + Bun.write( + path.join(exerciseConfigDirectory, "opencode.jsonc"), + JSON.stringify({ username: "httpapi-global" }, null, 2), + ), + ), + ) + .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) + .jsonEffect( + 200, + (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => + Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), + ) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status", + ), + http + .post("/global/dispose", "global.dispose") + .global() + .mutating() + .json( + 200, + (body) => { + check(body === true, "global dispose should return true") + }, + "status", + ), + http.get("/path", "path.get").json(200, (body, ctx) => { + object(body) + check(body.directory === ctx.directory, "directory should resolve from x-kilo-directory") + check(body.worktree === ctx.directory, "worktree should resolve from x-kilo-directory") + }), + http.get("/vcs", "vcs.get").json(), + http + .get("/vcs/diff", "vcs.diff") + .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) + .json(200, array), + http.get("/command", "command.list").json(200, array, "status"), + http.get("/agent", "app.agents").json(200, array, "status"), + http.get("/skill", "app.skills").json(200, array, "status"), + http.get("/lsp", "lsp.status").json(200, array), + http.get("/formatter", "formatter.status").json(200, array), + http.get("/config", "config.get").json(200, undefined, "status"), + http + .patch("/config", "config.update") + .mutating() + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) + .json( + 200, + (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, + "status", + ), + http + .patch("/config", "config.update.invalid") + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) + .status(400), + http.get("/config/providers", "config.providers").json(), + http.get("/project", "project.list").json(200, array, "status"), + http.get("/project/current", "project.current").json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, + "status", + ), + http + .patch("/project/{projectID}", "project.update") + .mutating() + .seeded((ctx) => ctx.project()) + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: ctx.state.id }), + headers: ctx.headers(), + body: { name: "HTTP API Project", commands: { start: "bun --version" } }, + })) + .json( + 200, + (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check( + isRecord(body.commands) && body.commands.start === "bun --version", + "project update should return patched command", + ) + }, + "status", + ), + http + .post("/project/git/init", "project.initGit") + .mutating() + .inProject({ git: false }) + .json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, + "status", + ), + http.get("/provider", "provider.list").json(), + http.get("/provider/auth", "provider.auth").json(), + http + .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) + .status(400), + http + .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) + .status(400), + http.get("/permission", "permission.list").json(200, array), + http + .post("/permission/{requestID}/reply", "permission.reply.invalid") + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "bad" }, + })) + .status(400), + http + .post("/permission/{requestID}/reply", "permission.reply") + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "once" }, + })) + .json(200, (body) => { + check(body === true, "permission reply should return true even when request is no longer pending") + }), + http.get("/question", "question.list").json(200, array), + http + .post("/question/{requestID}/reply", "question.reply.invalid") + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: "Yes" }, + })) + .status(400), + http + .post("/question/{requestID}/reply", "question.reply") + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: [["Yes"]] }, + })) + .json(200, (body) => { + check(body === true, "question reply should return true even when request is no longer pending") + }), + http + .post("/question/{requestID}/reject", "question.reject") + .at((ctx) => ({ + path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === true, "question reject should return true even when request is no longer pending") + }), + http + .get("/file", "file.list") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/file/content", "file.read") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) + }), + http + .get("/file/content", "file.read.missing") + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.content === "", "missing file content should return an empty text result") + }), + http.get("/file/status", "file.status").json(200, array), + http + .get("/find", "find.text") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/file", "find.files") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ + path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers: ctx.headers(), + })) + .json(200, array), + http + .get("/find/symbol", "find.symbols") + .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) + .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/event", "event.stream") + .stream() + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status", + ), + http.get("/mcp", "mcp.status").json(), + http + .post("/mcp", "mcp.add") + .mutating() + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, + })) + .json( + 200, + (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, + "status", + ), + http + .post("/mcp", "mcp.add.invalid") + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-invalid", config: { type: "invalid" } }, + })) + .status(400), + http + .post("/mcp/{name}/auth", "mcp.auth.start") + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, + "status", + ), + http + .delete("/mcp/{name}/auth", "mcp.auth.remove") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.success === true, "MCP auth removal should return success") + }), + http + .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") + .at((ctx) => ({ + path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), + headers: ctx.headers(), + })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, + "status", + ), + http + .post("/mcp/{name}/auth/callback", "mcp.auth.callback") + .at((ctx) => ({ + path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), + headers: ctx.headers(), + body: { code: 1 }, + })) + .status(400), + http + .post("/mcp/{name}/connect", "mcp.connect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP connect should remain a no-op success") + }), + http + .post("/mcp/{name}/disconnect", "mcp.disconnect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP disconnect should remain a no-op success") + }), + http.get("/pty/shells", "pty.shells").json(200, array), + http.get("/pty", "pty.list").json(200, array), + http + .post("/pty", "pty.create") + .mutating() + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, + "status", + ), + http + .post("/pty", "pty.create.invalid") + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) + .status(400), + http + .get("/pty/{ptyID}", "pty.get") + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .put("/pty/{ptyID}", "pty.update") + .mutating() + .at((ctx) => ({ + path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + body: { size: { rows: 0, cols: 0 } }, + })) + .status(400), + http + .delete("/pty/{ptyID}", "pty.remove") + .mutating() + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "PTY remove should return true") + }), + http + .get("/pty/{ptyID}/connect", "pty.connect") + .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404, undefined, "none"), + http.get("/experimental/console", "experimental.console.get").json(), + http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), + http + .post("/experimental/console/switch", "experimental.console.switchOrg") + .at((ctx) => ({ + path: "/experimental/console/switch", + headers: ctx.headers(), + body: { accountID: "httpapi-account", orgID: "httpapi-org" }, + })) + .status(400, undefined, "none"), + http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), + http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), + http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), + http + .post("/experimental/workspace", "experimental.workspace.create") + .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) + .status(400), + http + .delete("/experimental/workspace/{id}", "experimental.workspace.remove") + .mutating() + .at((ctx) => ({ + path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(200), + http + .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") + .at((ctx) => ({ + path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + body: {}, + })) + .status(400), + http + .get("/experimental/tool", "tool.list") + .at((ctx) => ({ + path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, + headers: ctx.headers(), + })) + .json(200, array, "status"), + http.get("/experimental/tool/ids", "tool.ids").json(200, array), + http.get("/experimental/worktree", "worktree.list").json(200, array), + http + .post("/experimental/worktree", "worktree.create") + .mutating() + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status", + ), + http + .post("/experimental/worktree", "worktree.create.invalid") + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) + .status(400), + http + .delete("/experimental/worktree", "worktree.remove") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-remove" })) + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .json(200, (body) => { + check(body === true, "worktree remove should return true") + }), + http + .post("/experimental/worktree/reset", "worktree.reset") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-reset" })) + .at((ctx) => ({ + path: "/experimental/worktree/reset", + headers: ctx.headers(), + body: { directory: ctx.state.directory }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "worktree reset should return true") + yield* ctx.worktreeRemove(ctx.state.directory) + }), + ), + http.get("/experimental/session", "experimental.session.list").json(200, array), + http.get("/experimental/resource", "experimental.resource.list").json(), + http + .post("/sync/history", "sync.history.list") + .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) + .json(200, array), + http + .post("/sync/replay", "sync.replay") + .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) + .status(400), + http + .post("/sync/start", "sync.start") + .mutating() + .preserveDatabase() + .json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http + .post("/instance/dispose", "instance.dispose") + .mutating() + .json(200, (body) => { + check(body === true, "instance dispose should return true") + }), + http + .post("/log", "app.log") + .global() + .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) + .json(200, (body) => { + check(body === true, "log route should return true") + }), + http + .put("/auth/{providerID}", "auth.set") + .global() + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth set should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") + }), + ), + http + .delete("/auth/{providerID}", "auth.remove") + .global() + .seeded(() => + Effect.promise(() => + Bun.write( + path.join(exerciseDataDirectory, "auth.json"), + JSON.stringify({ test: { type: "api", key: "remove-me" } }), + ), + ), + ) + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth remove should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(auth.test === undefined, "auth remove should delete provider from isolated auth file") + }), + ), + http + .get("/session", "session.list") + .seeded((ctx) => ctx.session({ title: "List me" })) + .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check( + body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), + "seeded session should be listed", + ) + }), + http + .get("/session/status", "session.status") + .seeded((ctx) => ctx.session({ title: "Status session" })) + .json(200, object), + http + .post("/session", "session.create") + .mutating() + .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, + "status", + ), + http + .get("/session/{sessionID}", "session.get") + .seeded((ctx) => ctx.session({ title: "Get me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "should return requested session") + check(body.title === "Get me", "should preserve seeded title") + }), + http + .get("/session/{sessionID}", "session.get.missing") + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(404), + http + .patch("/session/{sessionID}", "session.update") + .mutating() + .seeded((ctx) => ctx.session({ title: "Before rename" })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { title: "After rename" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, + "status", + ), + http + .patch("/session/{sessionID}", "session.update.invalid") + .mutating() + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: { title: 1 }, + })) + .status(400), + http + .delete("/session/{sessionID}", "session.delete") + .mutating() + .seeded((ctx) => ctx.session({ title: "Delete me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete should return true") + check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") + }), + ), + http + .get("/session/{sessionID}/children", "session.children") + .seeded((ctx) => + Effect.gen(function* () { + const parent = yield* ctx.session({ title: "Parent" }) + const child = yield* ctx.session({ title: "Child", parentID: parent.id }) + return { parent, child } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + array(body) + check( + body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), + "children should include seeded child", + ) + }), + http + .get("/session/{sessionID}/todo", "session.todo") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Todo session" }) + const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + yield* ctx.todos(session.id, todos) + return { session, todos } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") + }), + http + .get("/session/{sessionID}/diff", "session.diff") + .seeded((ctx) => ctx.session({ title: "Diff session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, array), + http + .get("/session/{sessionID}/message", "session.messages") + .seeded((ctx) => ctx.session({ title: "Messages session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + array(body) + check(body.length === 0, "new session should have no messages") + }), + http + .get("/session/{sessionID}/message/{messageID}", "session.message") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message get session" }) + const message = yield* ctx.message(session.id, { text: "read me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + object(body) + check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), + "message should include seeded part", + ) + }), + http + .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part update session" }) + const message = yield* ctx.message(session.id, { text: "before" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + body: { ...ctx.state.message.part, text: "after" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, + "status", + ), + http + .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part delete session" }) + const message = yield* ctx.message(session.id, { text: "delete part" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete part should return true") + const messages = yield* ctx.messages(ctx.state.session.id) + check(messages[0]?.parts.length === 0, "deleted part should not remain on message") + }), + ), + http + .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message delete session" }) + const message = yield* ctx.message(session.id, { text: "delete message" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete message should return true") + check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") + }), + ), + http + .post("/session/{sessionID}/fork", "session.fork") + .mutating() + .seeded((ctx) => ctx.session({ title: "Fork source" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: {}, + })) + .json( + 200, + (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, + "status", + ), + http + .post("/session/{sessionID}/abort", "session.abort") + .mutating() + .seeded((ctx) => ctx.session({ title: "Abort session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "abort should return true") + }), + http + .post("/session/{sessionID}/abort", "session.abort.missing") + .at((ctx) => ({ + path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === true, "missing session abort should remain a no-op success") + }), + http + .post("/session/{sessionID}/init", "session.init") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Init session" }) + const message = yield* ctx.message(session.id, { text: "initialize" }) + yield* ctx.llmText("initialized") + yield* ctx.llmText("initialized") + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "init should return true") + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/message", "session.prompt") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "LLM prompt session" }) + yield* ctx.llmText("fake assistant") + yield* ctx.llmText("fake assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), + "assistant message should use fake LLM text", + ) + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/prompt_async", "session.prompt_async") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Async prompt session" }) + yield* ctx.llmText("fake async assistant") + yield* ctx.llmText("fake async assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello async" }], + }, + })) + .status(204, (ctx) => + Effect.gen(function* () { + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/command", "session.command") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Command session" }) + yield* ctx.llmText("command done") + yield* ctx.llmText("command done") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { command: "init", arguments: "", model: "test/test-model" }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/shell", "session.shell") + .preserveDatabase() + .mutating() + .seeded((ctx) => ctx.session({ title: "Shell session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, + })) + .json( + 200, + (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), + "shell should return a tool part", + ) + }, + "status", + ), + http + .post("/session/{sessionID}/summarize", "session.summarize") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Summarize session" }) + yield* ctx.message(session.id, { text: "summarize this work" }) + const summary = [ + "## Goal", + "- Exercise session summarize.", + "", + "## Constraints & Preferences", + "- Use fake LLM.", + "", + "## Progress", + "### Done", + "- Summary generated.", + "", + "### In Progress", + "- (none)", + "", + "### Blocked", + "- (none)", + "", + "## Key Decisions", + "- Keep route local.", + "", + "## Next Steps", + "- (none)", + "", + "## Critical Context", + "- Test fixture.", + "", + "## Relevant Files", + "- script/httpapi-exercise.ts: scenario", + ].join("\n") + yield* ctx.llmText(summary) + yield* ctx.llmText(summary) + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", auto: false }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/revert", "session.revert") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Revert session" }) + const message = yield* ctx.message(session.id, { text: "revert me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { messageID: ctx.state.message.info.id }, + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check( + isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, + "revert should record reverted message", + ) + }, + "status", + ), + http + .post("/session/{sessionID}/unrevert", "session.unrevert") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unrevert session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, + "status", + ), + http + .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") + .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/permissions/{permissionID}", { + sessionID: ctx.state.id, + permissionID: "per_httpapi_deprecated", + }), + headers: ctx.headers(), + body: { response: "once" }, + })) + .json(200, (body) => { + check(body === true, "deprecated permission response should return true") + }), + http + .post("/session/{sessionID}/share", "session.share") + .mutating() + .seeded((ctx) => ctx.session({ title: "Share session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, + "status", + ), + http + .delete("/session/{sessionID}/share", "session.unshare") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unshare session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, + "status", + ), + http + .post("/tui/append-prompt", "tui.appendPrompt") + .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession.invalid") + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) + .status(400), + http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), + http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), + http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), + http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), + http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), + http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), + http + .post("/tui/execute-command", "tui.executeCommand") + .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) + .json(200, boolean, "status"), + http + .post("/tui/show-toast", "tui.showToast") + .at((ctx) => ({ + path: "/tui/show-toast", + headers: ctx.headers(), + body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, + })) + .json(200, boolean, "status"), + http + .post("/tui/publish", "tui.publish") + .at((ctx) => ({ + path: "/tui/publish", + headers: ctx.headers(), + body: { type: "tui.prompt.append", properties: { text: "published" } }, + })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession") + .seeded((ctx) => ctx.session({ title: "TUI select" })) + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) + .json(200, boolean, "status"), + http + .post("/tui/control/response", "tui.control.response") + .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) + .json(200, boolean, "status"), + http + .get("/tui/control/next", "tui.control.next") + .mutating() + .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) + .json( + 200, + (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, + "status", + ), + http + .post("/global/upgrade", "global.upgrade") + .global() + .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) + .status(400), +] + +const main = Effect.gen(function* () { + yield* Effect.addFinalizer(() => cleanupExercisePaths) + const options = parseOptions(Bun.argv.slice(2)) + const modules = yield* Effect.promise(() => runtime()) + const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) + const selected = scenarios.filter((scenario) => matches(options, scenario)) + const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) + const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + + printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) + + const results = + options.mode === "coverage" + ? selected.map(coverageResult) + : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + printResults(results, missing, extra) + + if (results.some((result) => result.status === "fail")) + return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) + return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) + return yield* Effect.fail(new Error("one or more routes have no scenario")) +}) + +function runScenario(options: Options) { + return (scenario: Scenario) => { + if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) + return runActive(options, scenario).pipe( + Effect.as({ status: "pass", scenario } as Result), + Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), + Effect.scoped, + ) + } +} + +function runActive(options: Options, scenario: ActiveScenario) { + if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { + return Effect.gen(function* () { + const effect = yield* runBackend("effect", scenario) + const legacy = yield* runBackend("legacy", scenario) + yield* compare(scenario, effect, legacy) + }) + } + + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const effect = yield* call("effect", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, effect) + if (options.mode === "parity" && scenario.compare !== "none") { + const legacy = yield* call("legacy", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, legacy) + yield* compare(scenario, effect, legacy) + } + }), + ) +} + +function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const result = yield* call(backend, scenario, ctx) + yield* scenario.expect(ctx, ctx.state, result) + return result + }), + ) +} + +function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { + return Effect.acquireRelease( + Effect.gen(function* () { + const llm = scenario.project?.llm ? yield* TestLLMServer : undefined + const project = scenario.project + const dir = project + ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) + : undefined + return { dir, llm } + }), + (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((context) => + Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), + ), + Effect.catchCause(() => Effect.failCause(cause)), + ), + ), + ) + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ + ...(context.dir?.path ? { "x-kilo-directory": context.dir.path } : {}), + ...extra, + }), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), + ), + Effect.ensuring(scenario.reset ? resetState : Effect.void), + ) +} + +function projectOptions( + project: ProjectOptions, + llmUrl: string | undefined, +): { git?: boolean; config?: Partial } { + if (!project.llm || !llmUrl) return { git: project.git, config: project.config } + const fake = fakeLlmConfig(llmUrl) + return { + git: project.git, + config: { + ...fake, + ...project.config, + provider: { + ...fake.provider, + ...project.config?.provider, + }, + }, + } +} + +function fakeLlmConfig(url: string): Partial { + return { + model: "test/test-model", + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + +function controlledPtyInput(title: string | undefined) { + return { + command: "/bin/sh", + args: ["-c", "sleep 30"], + ...(title ? { title } : {}), + } +} + +function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { + return Effect.promise(async () => + capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), + ) +} + +const appCache: Partial> = {} + +function app(modules: Runtime, backend: Backend) { + Flag.KILO_EXPERIMENTAL_HTTPAPI = backend === "effect" + Flag.KILO_SERVER_PASSWORD = undefined + Flag.KILO_SERVER_USERNAME = undefined + if (appCache[backend]) return appCache[backend] + if (backend === "legacy") { + const legacy = modules.Server.Legacy().app + return (appCache.legacy = { + request: (input, init) => legacy.request(input, init), + }) + } + + const handler = HttpRouter.toWebHandler( + modules.ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ KILO_SERVER_PASSWORD: undefined, KILO_SERVER_USERNAME: undefined }), + ), + ), + ), + { disableLogger: true }, + ).handler + return (appCache.effect = { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + modules.ExperimentalHttpApiServer.context, + ) + }, + }) +} + +function toRequest(scenario: ActiveScenario, ctx: SeededContext) { + const spec = scenario.request(ctx, ctx.state) + return new Request(new URL(spec.path, "http://localhost"), { + method: scenario.method, + headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + }) +} + +async function capture(response: Response, mode: CaptureMode): Promise { + const text = mode === "stream" ? await captureStream(response) : await response.text() + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + text, + body: parse(text), + } +} + +async function captureStream(response: Response) { + if (!response.body) return "" + const reader = response.body.getReader() + const read = reader.read().then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) + if ("timeout" in winner) { + await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) + throw new Error("timed out waiting for stream chunk") + } + if ("error" in winner) throw winner.error + await reader.cancel().catch(() => undefined) + if (winner.result.done) return "" + return new TextDecoder().decode(winner.result.value) +} + +const cleanupExercisePaths = Effect.promise(async () => { + const fs = await import("fs/promises") + if (!preserveExerciseDatabase) { + await Promise.all( + [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => + fs.rm(file, { force: true }).catch(() => undefined), + ), + ) + } + if (!preserveExerciseGlobalRoot) + await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) +}) + +function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { + return Effect.sync(() => { + if (effect.status !== legacy.status) + throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (scenario.compare === "status") return + if (stable(effect.body) !== stable(legacy.body)) + throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + }) +} + +const resetState = Effect.promise(async () => { + const modules = await runtime() + Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI + Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD + Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME + await modules.disposeAllInstances() + await modules.resetDatabase() + await Bun.sleep(25) +}) + +function routeKeys(spec: OpenApiSpec) { + return Object.entries(spec.paths ?? {}) + .flatMap(([path, item]) => + OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) + .sort() +} + +function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} + +function coverageResult(scenario: Scenario): Result { + if (scenario.kind === "todo") return { status: "skip", scenario } + return { status: "pass", scenario } +} + +function parseOptions(args: string[]): Options { + const mode = option(args, "--mode") ?? "effect" + if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) + return { + mode, + include: option(args, "--include"), + failOnMissing: args.includes("--fail-on-missing"), + failOnSkip: args.includes("--fail-on-skip"), + } +} + +function option(args: string[], name: string) { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +function matches(options: Options, scenario: Scenario) { + if (!options.include) return true + return ( + scenario.name.includes(options.include) || + scenario.path.includes(options.include) || + scenario.method.includes(options.include.toUpperCase()) + ) +} + +function printHeader( + options: Options, + effectRoutes: string[], + honoRoutes: string[], + selected: Scenario[], + missing: string[], + extra: Scenario[], +) { + console.log(`${color.cyan}HttpApi exerciser${color.reset}`) + console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) + console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) + console.log( + `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, + ) + console.log("") +} + +function printResults(results: Result[], missing: string[], extra: Scenario[]) { + for (const result of results) { + if (result.status === "pass") { + console.log( + `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) + continue + } + if (result.status === "skip") { + console.log( + `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, + ) + continue + } + console.log( + `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) + console.log(`${color.red}${indent(result.message)}${color.reset}`) + } + if (missing.length > 0) { + console.log("\nMissing scenarios") + for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) + } + if (extra.length > 0) { + console.log("\nExtra scenarios") + for (const scenario of extra) + console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + } + console.log( + `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, + ) +} + +function parse(text: string): unknown { + if (!text) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +function looksJson(result: CallResult) { + return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") +} + +function stable(value: unknown): string { + return JSON.stringify(sort(value)) +} + +function sort(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sort) + if (!value || typeof value !== "object") return value + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sort(item)]), + ) +} + +function array(value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) throw new Error("expected array") +} + +function object(value: unknown): asserts value is JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") +} + +function boolean(value: unknown): asserts value is boolean { + if (typeof value !== "boolean") throw new Error("expected boolean") +} + +function isRecord(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function check(value: boolean, message: string): asserts value { + if (!value) throw new Error(message) +} + +function message(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +function pad(value: string, size: number) { + return value.length >= size ? value : value + " ".repeat(size - value.length) +} + +function indent(value: string) { + return value + .split("\n") + .map((line) => ` ${line}`) + .join("\n") +} + +Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( + () => process.exit(0), + (error: unknown) => { + console.error(`${color.red}${message(error)}${color.reset}`) + process.exit(1) + }, +) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 8032261901..f0040e47c7 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -51,6 +51,7 @@ import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, KiloClient, SessionMessageResponse, ToolPart } from "@kilocode/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -129,7 +130,7 @@ async function sendUsageUpdate( }) } -export async function init({ sdk: _sdk }: { sdk: KiloClient }) { +export function init({ sdk: _sdk }: { sdk: KiloClient }) { return { create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { return new Agent(connection, fullConfig) @@ -144,7 +145,7 @@ export class Agent implements ACPAgent { private sessionManager: ACPSessionManager private eventAbort = new AbortController() private eventStarted = false - private bashSnapshots = new Map() + private shellSnapshots = new Map() private toolStarts = new Set() private permissionQueues = new Map>() private permissionOptions: PermissionOption[] = [ @@ -283,16 +284,16 @@ export class Agent implements ACPAgent { switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) return case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash") { - if (this.bashSnapshots.get(part.callID) === hash) { + if (part.tool === ShellID.ToolID) { + if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, @@ -311,7 +312,7 @@ export class Agent implements ACPAgent { }) return } - this.bashSnapshots.set(part.callID, hash) + this.shellSnapshots.set(part.callID, hash) } content.push({ type: "content", @@ -342,7 +343,7 @@ export class Agent implements ACPAgent { case "completed": { this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -423,7 +424,7 @@ export class Agent implements ACPAgent { } case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -837,10 +838,10 @@ export class Agent implements ACPAgent { await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) break case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const runningContent: ToolCallContent[] = [] if (output) { runningContent.push({ @@ -871,7 +872,7 @@ export class Agent implements ACPAgent { break case "completed": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -951,7 +952,7 @@ export class Agent implements ACPAgent { break case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -1105,8 +1106,8 @@ export class Agent implements ACPAgent { } } - private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + private shellOutput(part: ToolPart) { + if (part.tool !== ShellID.ToolID) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1549,9 +1550,11 @@ export class Agent implements ACPAgent { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() + switch (tool) { - case "bash": + case ShellID.ToolID: return "execute" + case "webfetch": return "fetch" @@ -1576,6 +1579,7 @@ function toToolKind(toolName: string): ToolKind { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() + switch (tool) { case "read": case "edit": @@ -1584,7 +1588,7 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] - case "bash": + case ShellID.ToolID: return [] default: return [] diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index cf9fcfbeec..3250c166ab 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -24,6 +24,7 @@ export function payloads() { .map(([type, def]) => { return z .object({ + id: z.string(), type: z.literal(type), properties: zodObject(def.properties), }) @@ -39,6 +40,7 @@ export function effectPayloads() { .entries() .map(([type, def]) => Schema.Struct({ + id: Schema.String, type: Schema.Literal(type), properties: def.properties, }).annotate({ identifier: `Event.${type}` }), diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index b5392a81b9..3cfd453624 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "events" +import { Identifier } from "@/id/id" export type GlobalEvent = { directory?: string @@ -7,6 +8,15 @@ export type GlobalEvent = { payload: any } -export const GlobalBus = new EventEmitter<{ +class GlobalBusEmitter extends EventEmitter<{ event: [GlobalEvent] -}>() +}> { + override emit(eventName: "event", event: GlobalEvent): boolean { + if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) { + event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending") + } + return super.emit(eventName, event) + } +} + +export const GlobalBus = new GlobalBusEmitter() diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 9ee8e6fb03..449694a53a 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -5,6 +5,7 @@ import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { Identifier } from "@/id/id" const log = Log.create({ service: "bus" }) @@ -18,6 +19,7 @@ export const InstanceDisposed = BusEvent.define( ) type Payload = { + id: string type: D["type"] properties: BusProperties } @@ -28,7 +30,11 @@ type State = { } export interface Interface { - readonly publish: (def: D, properties: BusProperties) => Effect.Effect + readonly publish: ( + def: D, + properties: BusProperties, + options?: { id?: string }, + ) => Effect.Effect readonly subscribe: (def: D) => Stream.Stream> readonly subscribeAll: () => Stream.Stream readonly subscribeCallback: ( @@ -53,6 +59,7 @@ export const layer = Layer.effect( // Publish InstanceDisposed before shutting down so subscribers see it yield* PubSub.publish(wildcard, { type: InstanceDisposed.type, + id: createID(), properties: { directory: ctx.directory }, }) yield* PubSub.shutdown(wildcard) @@ -77,10 +84,10 @@ export const layer = Layer.effect( }) } - function publish(def: D, properties: BusProperties) { + function publish(def: D, properties: BusProperties, options?: { id?: string }) { return Effect.gen(function* () { const s = yield* InstanceState.get(state) - const payload: Payload = { type: def.type, properties } + const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties } log.info("publishing", { type: def.type }) const ps = s.typed.get(def.type) @@ -173,8 +180,16 @@ const { runPromise, runSync } = makeRuntime(Service, layer) // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. -export async function publish(def: D, properties: BusProperties) { - return runPromise((svc) => svc.publish(def, properties)) +export function createID() { + return Identifier.create("evt", "ascending") +} + +export async function publish( + def: D, + properties: BusProperties, + options?: { id?: string }, +) { + return runPromise((svc) => svc.publish(def, properties, options)) } export function subscribe(def: D, callback: (event: Payload) => unknown) { diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index da90ec4033..fa39ecb177 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,17 +1,16 @@ import { Instance } from "../project/instance" -import { InstanceStore } from "../project/instance-store" -import { getBootstrapRunEffect } from "../effect/app-runtime" +import { InstanceRuntime } from "../project/instance-runtime" +import { WithInstance } from "../project/with-instance" export async function bootstrap(directory: string, cb: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, - init: await getBootstrapRunEffect(), fn: async () => { try { const result = await cb() return result } finally { - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) } }, }) diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 38c28032cd..e0755577b6 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { Account } from "@/account/account" import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd } from "../effect-cmd" import * as Prompt from "../effect/prompt" import open from "open" @@ -172,60 +172,65 @@ const openEffect = Effect.fn("open")(function* () { yield* Prompt.outro("Opened " + url) }) -export const LoginCommand = cmd({ +export const LoginCommand = effectCmd({ command: "login ", describe: false, + instance: false, builder: (yargs) => yargs.positional("url", { describe: "server URL", type: "string", demandOption: true, }), - async handler(args) { + handler: Effect.fn("Cli.account.login")(function* (args) { UI.empty() - await AppRuntime.runPromise(loginEffect(args.url)) - }, + yield* Effect.orDie(loginEffect(args.url)) + }), }) -export const LogoutCommand = cmd({ +export const LogoutCommand = effectCmd({ command: "logout [email]", describe: false, + instance: false, builder: (yargs) => yargs.positional("email", { describe: "account email to log out from", type: "string", }), - async handler(args) { + handler: Effect.fn("Cli.account.logout")(function* (args) { UI.empty() - await AppRuntime.runPromise(logoutEffect(args.email)) - }, + yield* Effect.orDie(logoutEffect(args.email)) + }), }) -export const SwitchCommand = cmd({ +export const SwitchCommand = effectCmd({ command: "switch", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.switch")(function* () { UI.empty() - await AppRuntime.runPromise(switchEffect()) - }, + yield* Effect.orDie(switchEffect()) + }), }) -export const OrgsCommand = cmd({ +export const OrgsCommand = effectCmd({ command: "orgs", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.orgs")(function* () { UI.empty() - await AppRuntime.runPromise(orgsEffect()) - }, + yield* Effect.orDie(orgsEffect()) + }), }) -export const OpenCommand = cmd({ +export const OpenCommand = effectCmd({ command: "open", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.open")(function* () { UI.empty() - await AppRuntime.runPromise(openEffect()) - }, + yield* Effect.orDie(openEffect()) + }), }) export const ConsoleCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 58431001be..f264bb62d6 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -1,15 +1,16 @@ import * as Log from "@opencode-ai/core/util/log" -import { bootstrap } from "../bootstrap" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" +import { ServerAuth } from "@/server/auth" import { createKiloClient } from "@kilocode/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) -export const AcpCommand = cmd({ +export const AcpCommand = effectCmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { @@ -19,52 +20,54 @@ export const AcpCommand = cmd({ default: process.cwd(), }) }, - handler: async (args) => { + handler: Effect.fn("Cli.acp")(function* (args) { process.env.KILO_CLIENT = "acp" - await bootstrap(process.cwd(), async () => { - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) - const sdk = createKiloClient({ - baseUrl: `http://${server.hostname}:${server.port}`, - }) - - const input = new WritableStream({ - write(chunk) { - return new Promise((resolve, reject) => { - process.stdout.write(chunk, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - }, - }) - const output = new ReadableStream({ - start(controller) { - process.stdin.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)) - }) - process.stdin.on("end", () => controller.close()) - process.stdin.on("error", (err) => controller.error(err)) - }, - }) - - const stream = ndJsonStream(input, output) - const agent = await ACP.init({ sdk }) - - new AgentSideConnection((conn) => { - return agent.create(conn, { sdk }) - }, stream) - - log.info("setup connection") - process.stdin.resume() - await new Promise((resolve, reject) => { - process.stdin.on("end", resolve) - process.stdin.on("error", reject) - }) + const sdk = createKiloClient({ + baseUrl: `http://${server.hostname}:${server.port}`, + headers: ServerAuth.headers(), }) - }, + + const input = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + process.stdout.write(chunk, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }, + }) + const output = new ReadableStream({ + start(controller) { + process.stdin.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + process.stdin.on("end", () => controller.close()) + process.stdin.on("error", (err) => controller.error(err)) + }, + }) + + const stream = ndJsonStream(input, output) + const agent = ACP.init({ sdk }) + + new AgentSideConnection((conn) => { + return agent.create(conn, { sdk }) + }, stream) + + log.info("setup connection") + process.stdin.resume() + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + process.stdin.on("end", () => resolve()) + process.stdin.on("error", reject) + }), + ) + }), }) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a1a440eaa1..2026d82324 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,6 +1,5 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" -import { AppRuntime } from "@/effect/app-runtime" import { UI } from "../ui" import { Global } from "@opencode-ai/core/global" import { Agent } from "../../agent/agent" @@ -9,9 +8,11 @@ import path from "path" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { EOL } from "os" import type { Argv } from "yargs" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" type AgentMode = "all" | "primary" | "subagent" @@ -32,7 +33,7 @@ const AVAILABLE_PERMISSIONS = [ "skill", ] -const AgentCreateCommand = cmd({ +const AgentCreateCommand = effectCmd({ command: "create", describe: "create a new agent", builder: (yargs: Argv) => @@ -60,200 +61,191 @@ const AgentCreateCommand = cmd({ alias: ["m"], describe: "model to use in the format of provider/model", }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - const cliPath = args.path - const cliDescription = args.description - const cliMode = args.mode as AgentMode | undefined - const perms = args.permissions + handler: Effect.fn("Cli.agent.create")(function* (args) { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + const agentSvc = yield* Agent.Service + yield* Effect.promise(async () => { + const cliPath = args.path + const cliDescription = args.description + const cliMode = args.mode as AgentMode | undefined + const perms = args.permissions - const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined + const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined - if (!isFullyNonInteractive) { - UI.empty() - prompts.intro("Create agent") - } + if (!isFullyNonInteractive) { + UI.empty() + prompts.intro("Create agent") + } - const project = Instance.project + const project = ctx.project - // Determine scope/path - let targetPath: string - if (cliPath) { - targetPath = path.join(cliPath, "agent") - } else { - let scope: "global" | "project" = "global" - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: "project" as const, - hint: Instance.worktree, - }, - { - label: "Global", - value: "global" as const, - hint: Global.Path.config, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - scope = scopeResult - } - targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"), - "agent", - ) - } - - // Get description - let description: string - if (cliDescription) { - description = cliDescription - } else { - const query = await prompts.text({ - message: "Description", - placeholder: "What should this agent do?", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(query)) throw new UI.CancelledError() - description = query - } - - // Generate agent - const spinner = prompts.spinner() - spinner.start("Generating agent configuration...") - const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await AppRuntime.runPromise( - Agent.Service.use((svc) => svc.generate({ description, model })), - ).catch((error) => { - spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) - if (isFullyNonInteractive) process.exit(1) - throw new UI.CancelledError() - }) - spinner.stop(`Agent ${generated.identifier} generated`) - - // Select permissions to allow - let selected: string[] - if (perms !== undefined) { - selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS - } else { - const result = await prompts.multiselect({ - message: "Select permissions to allow (Space to toggle)", - options: AVAILABLE_PERMISSIONS.map((permission) => ({ - label: permission, - value: permission, - })), - initialValues: AVAILABLE_PERMISSIONS, - }) - if (prompts.isCancel(result)) throw new UI.CancelledError() - selected = result - } - - // Get mode - let mode: AgentMode - if (cliMode) { - mode = cliMode - } else { - const modeResult = await prompts.select({ - message: "Agent mode", + // Determine scope/path + let targetPath: string + if (cliPath) { + targetPath = path.join(cliPath, "agent") + } else { + let scope: "global" | "project" = "global" + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "All", - value: "all" as const, - hint: "Can function in both primary and subagent roles", + label: "Current project", + value: "project" as const, + hint: ctx.worktree, }, { - label: "Primary", - value: "primary" as const, - hint: "Acts as a primary/main agent", - }, - { - label: "Subagent", - value: "subagent" as const, - hint: "Can be used as a subagent by other agents", + label: "Global", + value: "global" as const, + hint: Global.Path.config, }, ], - initialValue: "all" as const, }) - if (prompts.isCancel(modeResult)) throw new UI.CancelledError() - mode = modeResult + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + scope = scopeResult } + targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent") + } - // Build permissions config — deny anything not explicitly selected. - const permissions: Record = {} - for (const permission of AVAILABLE_PERMISSIONS) { - if (!selected.includes(permission)) { - permissions[permission] = "deny" - } + // Get description + let description: string + if (cliDescription) { + description = cliDescription + } else { + const query = await prompts.text({ + message: "Description", + placeholder: "What should this agent do?", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(query)) throw new UI.CancelledError() + description = query + } + + // Generate agent + const spinner = prompts.spinner() + spinner.start("Generating agent configuration...") + const model = args.model ? Provider.parseModel(args.model) : undefined + const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => { + spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) + if (isFullyNonInteractive) process.exit(1) + throw new UI.CancelledError() + }) + spinner.stop(`Agent ${generated.identifier} generated`) + + // Select permissions to allow + let selected: string[] + if (perms !== undefined) { + selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS + } else { + const result = await prompts.multiselect({ + message: "Select permissions to allow (Space to toggle)", + options: AVAILABLE_PERMISSIONS.map((permission) => ({ + label: permission, + value: permission, + })), + initialValues: AVAILABLE_PERMISSIONS, + }) + if (prompts.isCancel(result)) throw new UI.CancelledError() + selected = result + } + + // Get mode + let mode: AgentMode + if (cliMode) { + mode = cliMode + } else { + const modeResult = await prompts.select({ + message: "Agent mode", + options: [ + { + label: "All", + value: "all" as const, + hint: "Can function in both primary and subagent roles", + }, + { + label: "Primary", + value: "primary" as const, + hint: "Acts as a primary/main agent", + }, + { + label: "Subagent", + value: "subagent" as const, + hint: "Can be used as a subagent by other agents", + }, + ], + initialValue: "all" as const, + }) + if (prompts.isCancel(modeResult)) throw new UI.CancelledError() + mode = modeResult + } + + // Build permissions config — deny anything not explicitly selected. + const permissions: Record = {} + for (const permission of AVAILABLE_PERMISSIONS) { + if (!selected.includes(permission)) { + permissions[permission] = "deny" } + } - // Build frontmatter - const frontmatter: { - description: string - mode: AgentMode - permission?: Record - } = { - description: generated.whenToUse, - mode, - } - if (Object.keys(permissions).length > 0) { - frontmatter.permission = permissions - } + // Build frontmatter + const frontmatter: { + description: string + mode: AgentMode + permission?: Record + } = { + description: generated.whenToUse, + mode, + } + if (Object.keys(permissions).length > 0) { + frontmatter.permission = permissions + } - // Write file - const content = matter.stringify(generated.systemPrompt, frontmatter) - const filePath = path.join(targetPath, `${generated.identifier}.md`) + // Write file + const content = matter.stringify(generated.systemPrompt, frontmatter) + const filePath = path.join(targetPath, `${generated.identifier}.md`) - await fs.mkdir(targetPath, { recursive: true }) - - if (await Filesystem.exists(filePath)) { - if (isFullyNonInteractive) { - console.error(`Error: Agent file already exists: ${filePath}`) - process.exit(1) - } - prompts.log.error(`Agent file already exists: ${filePath}`) - throw new UI.CancelledError() - } - - await Filesystem.write(filePath, content) + await fs.mkdir(targetPath, { recursive: true }) + if (await Filesystem.exists(filePath)) { if (isFullyNonInteractive) { - console.log(filePath) - } else { - prompts.log.success(`Agent created: ${filePath}`) - prompts.outro("Done") + console.error(`Error: Agent file already exists: ${filePath}`) + process.exit(1) } - }, + prompts.log.error(`Agent file already exists: ${filePath}`) + throw new UI.CancelledError() + } + + await Filesystem.write(filePath, content) + + if (isFullyNonInteractive) { + console.log(filePath) + } else { + prompts.log.success(`Agent created: ${filePath}`) + prompts.outro("Done") + } }) - }, + }), }) -const AgentListCommand = cmd({ +const AgentListCommand = effectCmd({ command: "list", describe: "list all available agents", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - const sortedAgents = agents.sort((a, b) => { - if (a.native !== b.native) { - return a.native ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - for (const agent of sortedAgents) { - process.stdout.write(`${agent.name} (${agent.mode})` + EOL) - process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) - } - }, + handler: Effect.fn("Cli.agent.list")(function* () { + const agents = yield* Agent.Service.use((svc) => svc.list()) + const sortedAgents = agents.sort((a, b) => { + if (a.native !== b.native) { + return a.native ? -1 : 1 + } + return a.name.localeCompare(b.name) }) - }, + + for (const agent of sortedAgents) { + process.stdout.write(`${agent.name} (${agent.mode})` + EOL) + process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) + } + }), }) export const AgentCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index fe6d62d7b6..05af009b88 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[] } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index cff9a7f9cc..1a3f79396c 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -7,14 +7,13 @@ import { Session } from "@/session/session" import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" import { ToolRegistry } from "@/tool/registry" -import { Instance } from "../../../project/instance" import { Permission } from "../../../permission" import { iife } from "../../../util/iife" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd, fail } from "../../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" +import type { InstanceContext } from "@/project/instance" -export const AgentCommand = cmd({ +export const AgentCommand = effectCmd({ command: "agent ", describe: "show agent configuration details", builder: (yargs) => @@ -32,60 +31,60 @@ export const AgentCommand = cmd({ type: "string", description: "Tool params as JSON or a JS object literal", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const agentName = args.name as string - const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName))) - if (!agent) { - process.stderr.write( - `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, - ) - process.exit(1) - } - const availableTools = await getAvailableTools(agent) - const resolvedTools = await resolveTools(agent, availableTools) - const toolID = args.tool as string | undefined - if (toolID) { - const tool = availableTools.find((item) => item.id === toolID) - if (!tool) { - process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL) - process.exit(1) - } - if (resolvedTools[toolID] === false) { - process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL) - process.exit(1) - } - const params = parseToolParams(args.params as string | undefined) - const ctx = await createToolContext(agent) - const result = await tool.execute(params, ctx) - process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL) - return - } - - const output = { - ...agent, - tools: resolvedTools, - } - process.stdout.write(JSON.stringify(output, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.agent")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + return yield* run(args, ctx) + }), }) -async function getAvailableTools(agent: Agent.Info) { - return AppRuntime.runPromise( - Effect.gen(function* () { - const provider = yield* Provider.Service - const registry = yield* ToolRegistry.Service - const model = agent.model ?? (yield* provider.defaultModel()) - return yield* registry.tools({ - ...model, - agent, - }) - }), - ) -} +const run = Effect.fn("Cli.debug.agent.body")(function* ( + args: { name: string; tool?: string; params?: string }, + ctx: InstanceContext, +) { + const agentName = args.name + const agent = yield* Agent.Service.use((svc) => svc.get(agentName)) + if (!agent) { + process.stderr.write( + `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, + ) + return yield* fail("", 1) + } + const availableTools = yield* getAvailableTools(agent) + const resolvedTools = resolveTools(agent, availableTools) + const toolID = args.tool + if (toolID) { + const tool = availableTools.find((item) => item.id === toolID) + if (!tool) { + process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL) + return yield* fail("", 1) + } + if (resolvedTools[toolID] === false) { + process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL) + return yield* fail("", 1) + } + const params = parseToolParams(args.params) + const toolCtx = yield* createToolContext(agent, ctx) + const result = yield* tool.execute(params, toolCtx) + process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL) + return + } -async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { + const output = { + ...agent, + tools: resolvedTools, + } + process.stdout.write(JSON.stringify(output, null, 2) + EOL) +}) + +const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) { + const provider = yield* Provider.Service + const registry = yield* ToolRegistry.Service + const model = agent.model ?? (yield* provider.defaultModel()) + return yield* registry.tools({ ...model, agent }) +}) + +function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) { const disabled = Permission.disabled( availableTools.map((tool) => tool.id), agent.permission, @@ -123,50 +122,38 @@ function parseToolParams(input?: string) { return parsed as Record } -async function createToolContext(agent: Agent.Info) { - const { session, messageID } = await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const result = yield* session.create({ title: `Debug tool run (${agent.name})` }) - const messageID = MessageID.ascending() - const model = agent.model - ? agent.model - : yield* Effect.gen(function* () { - const provider = yield* Provider.Service - return yield* provider.defaultModel() - }) - const now = Date.now() - const message: MessageV2.Assistant = { - id: messageID, - sessionID: result.id, - role: "assistant", - time: { - created: now, - }, - parentID: messageID, - modelID: model.modelID, - providerID: model.providerID, - mode: "debug", - agent: agent.name, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - } - yield* session.updateMessage(message) - return { session: result, messageID } - }), - ) +const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* ( + agent: Agent.Info, + ctx: InstanceContext, +) { + const sessionSvc = yield* Session.Service + const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` }) + const messageID = MessageID.ascending() + const model = agent.model + ? agent.model + : yield* Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.defaultModel() + }) + const now = Date.now() + const message: MessageV2.Assistant = { + id: messageID, + sessionID: session.id, + role: "assistant", + time: { created: now }, + parentID: messageID, + modelID: model.modelID, + providerID: model.providerID, + mode: "debug", + agent: agent.name, + path: { + cwd: ctx.directory, + root: ctx.worktree, + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } + yield* sessionSvc.updateMessage(message) const ruleset = Permission.merge(agent.permission, session.permission ?? []) @@ -189,4 +176,4 @@ async function createToolContext(agent: Agent.Info) { }) }, } -} +}) diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index a80b6a5819..15bd1c1a92 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -1,17 +1,14 @@ import { EOL } from "os" +import { Effect } from "effect" import { Config } from "@/config/config" -import { AppRuntime } from "@/effect/app-runtime" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" +import { effectCmd } from "../../effect-cmd" -export const ConfigCommand = cmd({ +export const ConfigCommand = effectCmd({ command: "config", describe: "show resolved configuration", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - process.stdout.write(JSON.stringify(config, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.config")(function* () { + const config = yield* Config.Service.use((cfg) => cfg.get()) + process.stdout.write(JSON.stringify(config, null, 2) + EOL) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 8e4eaa4e4d..d9bb252ea9 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,11 +1,11 @@ import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { File } from "../../../file" import { Ripgrep } from "@/file/ripgrep" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -const FileSearchCommand = cmd({ +const FileSearchCommand = effectCmd({ command: "search ", describe: "search files by query", builder: (yargs) => @@ -14,15 +14,13 @@ const FileSearchCommand = cmd({ demandOption: true, description: "Search query", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query }))) - process.stdout.write(results.join(EOL) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.file.search")(function* (args) { + const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) + process.stdout.write(results.join(EOL) + EOL) + }), }) -const FileReadCommand = cmd({ +const FileReadCommand = effectCmd({ command: "read ", describe: "read file contents as JSON", builder: (yargs) => @@ -31,27 +29,23 @@ const FileReadCommand = cmd({ demandOption: true, description: "File path to read", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path))) - process.stdout.write(JSON.stringify(content, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.file.read")(function* (args) { + const content = yield* File.Service.use((svc) => svc.read(args.path)) + process.stdout.write(JSON.stringify(content, null, 2) + EOL) + }), }) -const FileStatusCommand = cmd({ +const FileStatusCommand = effectCmd({ command: "status", describe: "show file status information", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status())) - process.stdout.write(JSON.stringify(status, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.file.status")(function* () { + const status = yield* File.Service.use((svc) => svc.status()) + process.stdout.write(JSON.stringify(status, null, 2) + EOL) + }), }) -const FileListCommand = cmd({ +const FileListCommand = effectCmd({ command: "list ", describe: "list files in a directory", builder: (yargs) => @@ -60,15 +54,13 @@ const FileListCommand = cmd({ demandOption: true, description: "File path to list", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path))) - process.stdout.write(JSON.stringify(files, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.file.list")(function* (args) { + const files = yield* File.Service.use((svc) => svc.list(args.path)) + process.stdout.write(JSON.stringify(files, null, 2) + EOL) + }), }) -const FileTreeCommand = cmd({ +const FileTreeCommand = effectCmd({ command: "tree [dir]", describe: "show directory tree", builder: (yargs) => @@ -77,12 +69,10 @@ const FileTreeCommand = cmd({ description: "Directory to tree", default: process.cwd(), }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) - console.log(JSON.stringify(tree, null, 2)) - }) - }, + handler: Effect.fn("Cli.debug.file.tree")(function* (args) { + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + console.log(JSON.stringify(tree, null, 2)) + }), }) export const FileCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 194e66b1f2..e545818efa 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -1,5 +1,11 @@ import { Global } from "@opencode-ai/core/global" -import { bootstrap } from "../../bootstrap" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Flag } from "@opencode-ai/core/flag/flag" +import os from "os" +import { Duration, Effect } from "effect" +import { Config } from "@/config/config" +import { ConfigPlugin } from "@/config/plugin" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { ConfigCommand } from "./config" import { FileCommand } from "./file" @@ -25,20 +31,49 @@ export const DebugCommand = cmd({ .command(SnapshotCommand) .command(StartupCommand) .command(AgentCommand) + .command(InfoCommand) .command(PathsCommand) - .command({ - command: "wait", - describe: "wait indefinitely (for debugging)", - async handler() { - await bootstrap(process.cwd(), async () => { - await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24)) - }) - }, - }) + .command(WaitCommand) .demandCommand(), async handler() {}, }) +const WaitCommand = effectCmd({ + command: "wait", + describe: "wait indefinitely (for debugging)", + handler: Effect.fn("Cli.debug.wait")(function* () { + yield* Effect.sleep(Duration.days(1)) + }), +}) + +const InfoCommand = effectCmd({ + command: "info", + describe: "show debug information", + handler: Effect.fn("Cli.debug.info")(function* () { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const termProgram = process.env.TERM_PROGRAM + ? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}` + : undefined + const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ") + + console.log(`opencode version: ${InstallationVersion}`) + console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`) + console.log(`terminal: ${terminal || "unknown"}`) + console.log("plugins:") + if (Flag.KILO_PURE) { + console.log("external plugins disabled (--pure)") + return + } + if (!config.plugin_origins?.length) { + console.log("none") + return + } + for (const plugin of config.plugin_origins) { + console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`) + } + }), +}) + const PathsCommand = cmd({ command: "paths", describe: "show global paths (data, config, cache, state)", diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 6312afcf18..b40b423181 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -1,7 +1,6 @@ import { LSP } from "@/lsp/lsp" -import { AppRuntime } from "../../../effect/app-runtime" import { Effect } from "effect" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import * as Log from "@opencode-ai/core/util/log" import { EOL } from "os" @@ -14,47 +13,39 @@ export const LSPCommand = cmd({ async handler() {}, }) -const DiagnosticsCommand = cmd({ +const DiagnosticsCommand = effectCmd({ command: "diagnostics ", describe: "get diagnostics for a file", builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const out = await AppRuntime.runPromise( - LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.touchFile(args.file, "full") - return yield* lsp.diagnostics() - }), - ), - ) - process.stdout.write(JSON.stringify(out, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) { + const out = yield* LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, "full") + return yield* lsp.diagnostics() + }), + ) + process.stdout.write(JSON.stringify(out, null, 2) + EOL) + }), }) -export const SymbolsCommand = cmd({ +export const SymbolsCommand = effectCmd({ command: "symbols ", describe: "search workspace symbols", builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - using _ = Log.Default.time("symbols") - const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { + using _ = Log.Default.time("symbols") + const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) + }), }) -export const DocumentSymbolsCommand = cmd({ +export const DocumentSymbolsCommand = effectCmd({ command: "document-symbols ", describe: "get symbols from a document", builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - using _ = Log.Default.time("document-symbols") - const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { + using _ = Log.Default.time("document-symbols") + const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 9b7e826915..8d1cbd2b1e 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,10 +1,9 @@ import { EOL } from "os" import { Effect, Stream } from "effect" -import { AppRuntime } from "../../../effect/app-runtime" import { Ripgrep } from "../../../file/ripgrep" -import { Instance } from "../../../project/instance" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" +import { InstanceRef } from "@/effect/instance-ref" export const RipgrepCommand = cmd({ command: "rg", @@ -13,24 +12,22 @@ export const RipgrepCommand = cmd({ async handler() {}, }) -const TreeCommand = cmd({ +const TreeCommand = effectCmd({ command: "tree", describe: "show file tree using ripgrep", builder: (yargs) => yargs.option("limit", { type: "number", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const tree = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })), - ) - process.stdout.write(tree + EOL) - }) - }, + handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) + process.stdout.write(tree + EOL) + }), }) -const FilesCommand = cmd({ +const FilesCommand = effectCmd({ command: "files", describe: "list files using ripgrep", builder: (yargs) => @@ -47,29 +44,26 @@ const FilesCommand = cmd({ type: "number", description: "Limit number of results", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise( - Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg - .files({ - cwd: Instance.directory, - glob: args.glob ? [args.glob] : undefined, - }) - .pipe( - Stream.take(args.limit ?? Infinity), - Stream.runCollect, - Effect.map((c) => [...c]), - ) - }), + handler: Effect.fn("Cli.debug.rg.files")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const rg = yield* Ripgrep.Service + const files = yield* rg + .files({ + cwd: ctx.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + Effect.orDie, ) - process.stdout.write(files.join(EOL) + EOL) - }) - }, + process.stdout.write(files.join(EOL) + EOL) + }), }) -const SearchCommand = cmd({ +const SearchCommand = effectCmd({ command: "search ", describe: "search file contents using ripgrep", builder: (yargs) => @@ -87,19 +81,19 @@ const SearchCommand = cmd({ type: "number", description: "Limit number of results", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => - svc.search({ - cwd: Instance.directory, - pattern: args.pattern, - glob: args.glob as string[] | undefined, - limit: args.limit, - }), - ), - ) - process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.rg.search")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const results = yield* Effect.orDie( + Ripgrep.Service.use((svc) => + svc.search({ + cwd: ctx.directory, + pattern: args.pattern, + glob: args.glob as string[] | undefined, + limit: args.limit, + }), + ), + ) + process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts index 79179411b6..3b120da3cb 100644 --- a/packages/opencode/src/cli/cmd/debug/skill.ts +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -1,23 +1,15 @@ import { EOL } from "os" import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" import { Skill } from "../../../skill" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" +import { effectCmd } from "../../effect-cmd" -export const SkillCommand = cmd({ +export const SkillCommand = effectCmd({ command: "skill", describe: "list all available skills", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const skills = await AppRuntime.runPromise( - Effect.gen(function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) - process.stdout.write(JSON.stringify(skills, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.skill")(function* () { + const skill = yield* Skill.Service + const skills = yield* skill.all() + process.stdout.write(JSON.stringify(skills, null, 2) + EOL) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/snapshot.ts b/packages/opencode/src/cli/cmd/debug/snapshot.ts index 6663398a45..e37e63dc47 100644 --- a/packages/opencode/src/cli/cmd/debug/snapshot.ts +++ b/packages/opencode/src/cli/cmd/debug/snapshot.ts @@ -1,6 +1,6 @@ -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { Snapshot } from "../../../snapshot" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" export const SnapshotCommand = cmd({ @@ -10,17 +10,16 @@ export const SnapshotCommand = cmd({ async handler() {}, }) -const TrackCommand = cmd({ +const TrackCommand = effectCmd({ command: "track", describe: "track current snapshot state", - async handler() { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track()))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.track")(function* () { + const out = yield* Snapshot.Service.use((svc) => svc.track()) + console.log(out) + }), }) -const PatchCommand = cmd({ +const PatchCommand = effectCmd({ command: "patch ", describe: "show patch for a snapshot hash", builder: (yargs) => @@ -29,14 +28,13 @@ const PatchCommand = cmd({ description: "hash", demandOption: true, }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash)))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) { + const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) + console.log(out) + }), }) -const DiffCommand = cmd({ +const DiffCommand = effectCmd({ command: "diff ", describe: "show diff for a snapshot hash", builder: (yargs) => @@ -45,9 +43,8 @@ const DiffCommand = cmd({ description: "hash", demandOption: true, }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash)))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) { + const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) + console.log(out) + }), }) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 62ba20e2ca..bf73ce941e 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -1,13 +1,11 @@ -import type { Argv } from "yargs" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" function redact(kind: string, id: string, value: string) { return value.trim() ? `[redacted:${kind}:${id}]` : value @@ -220,11 +218,11 @@ function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) } } -export const ExportCommand = cmd({ +export const ExportCommand = effectCmd({ command: "export [sessionID]", describe: "export session data as JSON", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("sessionID", { describe: "session id to export", type: "string", @@ -232,72 +230,62 @@ export const ExportCommand = cmd({ .option("sanitize", { describe: "redact sensitive transcript and file data", type: "boolean", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined - process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) - - if (!sessionID) { - UI.empty() - prompts.intro("Export session", { - output: process.stderr, - }) - - const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list())) - - if (sessions.length === 0) { - prompts.log.error("No sessions found", { - output: process.stderr, - }) - prompts.outro("Done", { - output: process.stderr, - }) - return - } - - sessions.sort((a, b) => b.time.updated - a.time.updated) - - const selectedSession = await prompts.autocomplete({ - message: "Select session to export", - maxItems: 10, - options: sessions.map((session) => ({ - label: session.title, - value: session.id, - hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, - })), - output: process.stderr, - }) - - if (prompts.isCancel(selectedSession)) { - throw new UI.CancelledError() - } - - sessionID = selectedSession - - prompts.outro("Exporting session...", { - output: process.stderr, - }) - } - - try { - const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!))) - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })), - ) - - const exportData = { - info: sessionInfo, - messages, - } - - process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) - process.stdout.write(EOL) - } catch { - UI.error(`Session not found: ${sessionID!}`) - process.exit(1) - } - }) - }, + }), + handler: Effect.fn("Cli.export")(function* (args) { + return yield* run(args) + }), +}) + +const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) { + const svc = yield* Session.Service + let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined + process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) + + if (!sessionID) { + UI.empty() + prompts.intro("Export session", { output: process.stderr }) + + const sessions = yield* svc.list() + + if (sessions.length === 0) { + prompts.log.error("No sessions found", { output: process.stderr }) + prompts.outro("Done", { output: process.stderr }) + return + } + + sessions.sort((a, b) => b.time.updated - a.time.updated) + + const selectedSession = yield* Effect.promise(() => + prompts.autocomplete({ + message: "Select session to export", + maxItems: 10, + options: sessions.map((session) => ({ + label: session.title, + value: session.id, + hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, + })), + output: process.stderr, + }), + ) + + if (prompts.isCancel(selectedSession)) { + return yield* Effect.die(new UI.CancelledError()) + } + + sessionID = selectedSession + + prompts.outro("Exporting session...", { output: process.stderr }) + } + + // Match legacy try/catch — catches both typed failures and defects + // (Session.Service.get throws NotFoundError as a defect, not a typed E). + return yield* Effect.gen(function* () { + const sessionInfo = yield* svc.get(sessionID!) + const messages = yield* svc.messages({ sessionID: sessionInfo.id }) + + const exportData = { info: sessionInfo, messages } + + process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) + process.stdout.write(EOL) + }).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`))) }) diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index 2219a0fd9b..5ec9cee366 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,22 +1,28 @@ import { Server } from "../../server/server" -import { PublicApi } from "../../server/routes/instance/httpapi/public" import type { CommandModule } from "yargs" -import { OpenApi } from "effect/unstable/httpapi" type Args = { httpapi: boolean + hono: boolean } export const GenerateCommand = { command: "generate", builder: (yargs) => - yargs.option("httpapi", { - type: "boolean", - default: false, - description: "Generate OpenAPI from the experimental Effect HttpApi contract", - }), + yargs + .option("httpapi", { + type: "boolean", + default: false, + description: + "Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)", + }) + .option("hono", { + type: "boolean", + default: false, + description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)", + }), handler: async (args) => { - const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi() + const specs = args.hono ? await Server.openapiHono() : await Server.openapi() for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 106d484662..ea5b35ef78 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -18,9 +18,9 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { ModelsDev } from "@/provider/models" -import { Instance } from "@/project/instance" -import { bootstrap } from "../bootstrap" +import { InstanceRef } from "@/effect/instance-ref" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" @@ -29,7 +29,6 @@ import { Provider } from "@/provider/provider" import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" -import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" @@ -199,191 +198,194 @@ export const GithubCommand = cmd({ async handler() {}, }) -export const GithubInstallCommand = cmd({ +export const GithubInstallCommand = effectCmd({ command: "install", describe: "install the GitHub agent", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - { - UI.empty() - prompts.intro("Install GitHub agent") - const app = await getAppInfo() - await installGitHubApp() + handler: Effect.fn("Cli.github.install")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + const modelsDev = yield* ModelsDev.Service + const gitSvc = yield* Git.Service + yield* Effect.promise(async () => { + { + UI.empty() + prompts.intro("Install GitHub agent") + const app = await getAppInfo() + await installGitHubApp() - const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { - // TODO: add guide for copilot, for now just hide it - delete p["github-copilot"] - return p + const providers = await Effect.runPromise(modelsDev.get()).then((p) => { + // TODO: add guide for copilot, for now just hide it + delete p["github-copilot"] + return p + }) + + const provider = await promptProvider() + const model = await promptModel() + //const key = await promptKey() + + await addWorkflowFiles() + printNextSteps() + + function printNextSteps() { + let step2 + if (provider === "amazon-bedrock") { + step2 = + "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" + } else { + step2 = [ + ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, + "", + ...providers[provider].env.map((e) => ` - ${e}`), + ].join("\n") + } + + prompts.outro( + [ + "Next steps:", + "", + ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, + step2, + "", + " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", + "", + " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", + ].join("\n"), + ) + } + + async function getAppInfo() { + const project = ctx.project + if (project.vcs !== "git") { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() + } + + // Get repo info + const info = await Effect.runPromise(gitSvc.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })).then( + (x) => x.text().trim(), + ) + const parsed = parseGitHubRemote(info) + if (!parsed) { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() + } + return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } + } + + async function promptProvider() { + const priority: Record = { + opencode: 0, + anthropic: 1, + openai: 2, + google: 3, + } + let provider = await prompts.select({ + message: "Select provider", + maxItems: 8, + options: pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: priority[x.id] === 0 ? "recommended" : undefined, + })), + ), }) - const provider = await promptProvider() - const model = await promptModel() - //const key = await promptKey() + if (prompts.isCancel(provider)) throw new UI.CancelledError() - await addWorkflowFiles() - printNextSteps() + return provider + } - function printNextSteps() { - let step2 - if (provider === "amazon-bedrock") { - step2 = - "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" - } else { - step2 = [ - ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, - "", - ...providers[provider].env.map((e) => ` - ${e}`), - ].join("\n") + async function promptModel() { + const providerData = providers[provider]! + + const model = await prompts.select({ + message: "Select model", + maxItems: 8, + options: pipe( + providerData.models, + values(), + sortBy((x) => x.name ?? x.id), + map((x) => ({ + label: x.name ?? x.id, + value: x.id, + })), + ), + }) + + if (prompts.isCancel(model)) throw new UI.CancelledError() + return model + } + + async function installGitHubApp() { + const s = prompts.spinner() + s.start("Installing GitHub app") + + // Get installation + const installation = await getInstallation() + if (installation) return s.stop("GitHub app already installed") + + // Open browser + const url = "https://github.com/apps/opencode-agent" + const command = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"` + + exec(command, (error) => { + if (error) { + prompts.log.warn(`Could not open browser. Please visit: ${url}`) } + }) - prompts.outro( - [ - "Next steps:", - "", - ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, - step2, - "", - " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", - "", - " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", - ].join("\n"), - ) - } - - async function getAppInfo() { - const project = Instance.project - if (project.vcs !== "git") { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - - // Get repo info - const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })), - ).then((x) => x.text().trim()) - const parsed = parseGitHubRemote(info) - if (!parsed) { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree } - } - - async function promptProvider() { - const priority: Record = { - opencode: 0, - anthropic: 1, - openai: 2, - google: 3, - } - let provider = await prompts.select({ - message: "Select provider", - maxItems: 8, - options: pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: priority[x.id] === 0 ? "recommended" : undefined, - })), - ), - }) - - if (prompts.isCancel(provider)) throw new UI.CancelledError() - - return provider - } - - async function promptModel() { - const providerData = providers[provider]! - - const model = await prompts.select({ - message: "Select model", - maxItems: 8, - options: pipe( - providerData.models, - values(), - sortBy((x) => x.name ?? x.id), - map((x) => ({ - label: x.name ?? x.id, - value: x.id, - })), - ), - }) - - if (prompts.isCancel(model)) throw new UI.CancelledError() - return model - } - - async function installGitHubApp() { - const s = prompts.spinner() - s.start("Installing GitHub app") - - // Get installation + // Wait for installation + s.message("Waiting for GitHub app to be installed") + const MAX_RETRIES = 120 + let retries = 0 + do { const installation = await getInstallation() - if (installation) return s.stop("GitHub app already installed") + if (installation) break - // Open browser - const url = "https://github.com/apps/opencode-agent" - const command = - process.platform === "darwin" - ? `open "${url}"` - : process.platform === "win32" - ? `start "" "${url}"` - : `xdg-open "${url}"` - - exec(command, (error) => { - if (error) { - prompts.log.warn(`Could not open browser. Please visit: ${url}`) - } - }) - - // Wait for installation - s.message("Waiting for GitHub app to be installed") - const MAX_RETRIES = 120 - let retries = 0 - do { - const installation = await getInstallation() - if (installation) break - - if (retries > MAX_RETRIES) { - s.stop( - `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, - ) - throw new UI.CancelledError() - } - - retries++ - await sleep(1000) - } while (true) // oxlint-disable-line no-constant-condition - - s.stop("Installed GitHub app") - - async function getInstallation() { - return await fetch( - `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + if (retries > MAX_RETRIES) { + s.stop( + `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, ) - .then((res) => res.json()) - .then((data) => data.installation) + throw new UI.CancelledError() } + + retries++ + await sleep(1000) + } while (true) // oxlint-disable-line no-constant-condition + + s.stop("Installed GitHub app") + + async function getInstallation() { + return await fetch( + `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + ) + .then((res) => res.json()) + .then((data) => data.installation) } + } - async function addWorkflowFiles() { - const envStr = - provider === "amazon-bedrock" - ? "" - : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` + async function addWorkflowFiles() { + const envStr = + provider === "amazon-bedrock" + ? "" + : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` - await Filesystem.write( - path.join(app.root, WORKFLOW_FILE), - `name: opencode + await Filesystem.write( + path.join(app.root, WORKFLOW_FILE), + `name: opencode on: issue_comment: @@ -414,17 +416,16 @@ jobs: uses: anomalyco/opencode/github@latest${envStr} with: model: ${provider}/${model}`, - ) + ) - prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) - } + prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } - }, + } }) - }, + }), }) -export const GithubRunCommand = cmd({ +export const GithubRunCommand = effectCmd({ command: "run", describe: "run the GitHub agent", builder: (yargs) => @@ -437,8 +438,14 @@ export const GithubRunCommand = cmd({ type: "string", describe: "GitHub personal access token (github_pat_********)", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.github.run")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + const gitSvc = yield* Git.Service + const sessionSvc = yield* Session.Service + const sessionShare = yield* SessionShare.Service + const sessionPrompt = yield* SessionPrompt.Service + yield* Effect.promise(async () => { const isMock = args.token || args.event const context = isMock ? (JSON.parse(args.event!) as Context) : github.context @@ -501,21 +508,20 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } - const gitStatus = (args: string[]) => - AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const gitStatus = (args: string[]) => Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) @@ -552,24 +558,22 @@ export const GithubRunCommand = cmd({ // Setup opencode session const repoData = await fetchRepo() - session = await AppRuntime.runPromise( - Session.Service.use((svc) => - svc.create({ - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - }), - ), + session = await Effect.runPromise( + sessionSvc.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }), ) subscribeSessionEvents() shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id))) + await Effect.runPromise(sessionShare.share(session.id)) return session.id.slice(-8) })() console.log("opencode session", session.id) @@ -879,7 +883,7 @@ export const GithubRunCommand = cmd({ function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], + bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], @@ -942,9 +946,9 @@ export const GithubRunCommand = cmd({ async function chat(message: string, files: PromptFiles = []) { console.log("Sending message to opencode...") - return AppRuntime.runPromise( + return Effect.runPromise( Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service + const prompt = sessionPrompt const result = yield* prompt.prompt({ sessionID: session.id, messageID: MessageID.ascending(), @@ -1645,5 +1649,5 @@ query($owner: String!, $repo: String!, $number: Int!) { }) } }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 997c2ea894..e768accaba 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,17 +1,14 @@ -import type { Argv } from "yargs" import type { Session as SDKSession, Message, Part } from "@kilocode/sdk/v2" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { ShareNext } from "@/share/share-next" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" -import { AppRuntime } from "@/effect/app-runtime" -import { Schema } from "effect" +import { Effect, Schema } from "effect" const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info) const decodePart = Schema.decodeUnknownSync(MessageV2.Part) @@ -78,135 +75,143 @@ export function transformShareData(shareData: ShareData[]): { } } -export const ImportCommand = cmd({ +type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } + +export const ImportCommand = effectCmd({ command: "import ", describe: "import session data from JSON file or URL", - builder: (yargs: Argv) => { - return yargs.positional("file", { + builder: (yargs) => + yargs.positional("file", { describe: "path to JSON file or share URL", type: "string", demandOption: true, + }), + handler: Effect.fn("Cli.import")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + return yield* runImport(args.file, ctx.project.id) + }), +}) + +const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) { + const share = yield* ShareNext.Service + + let exportData: ExportData | undefined + + const isUrl = file.startsWith("http://") || file.startsWith("https://") + + if (isUrl) { + const slug = parseShareUrl(file) + if (!slug) { + const baseUrl = yield* Effect.orDie(share.url()) + process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) + process.stdout.write(EOL) + return + } + + const baseUrl = new URL(file).origin + const req = yield* Effect.orDie(share.request()) + const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {} + + const tryFetch = (url: string) => + Effect.tryPromise({ + try: () => fetch(url, { headers }), + catch: (e) => + new CliError({ + message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`, + }), + }) + + const dataPath = req.api.data(slug) + let response = yield* tryFetch(`${baseUrl}${dataPath}`) + + if (!response.ok && dataPath !== `/api/share/${slug}/data`) { + response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`) + } + + if (!response.ok) { + process.stdout.write(`Failed to fetch share data: ${response.statusText}`) + process.stdout.write(EOL) + return + } + + const shareData = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new CliError({ message: "Share data was not valid JSON" }), }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let exportData: - | { - info: SDKSession - messages: Array<{ - info: Message - parts: Part[] - }> - } - | undefined + const transformed = transformShareData(shareData) - const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") + if (!transformed) { + process.stdout.write(`Share not found or empty: ${slug}`) + process.stdout.write(EOL) + return + } - if (isUrl) { - const slug = parseShareUrl(args.file) - if (!slug) { - const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url())) - process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) - process.stdout.write(EOL) - return - } + exportData = transformed + } else { + exportData = yield* Effect.promise(() => + Filesystem.readJson>(file).catch(() => undefined), + ) + if (!exportData) { + process.stdout.write(`File not found: ${file}`) + process.stdout.write(EOL) + return + } + } - const parsed = new URL(args.file) - const baseUrl = parsed.origin - const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request())) - const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} + if (!exportData) { + process.stdout.write(`Failed to read session data`) + process.stdout.write(EOL) + return + } - const dataPath = req.api.data(slug) - let response = await fetch(`${baseUrl}${dataPath}`, { - headers, + const info = Schema.decodeUnknownSync(Session.Info)({ + ...exportData.info, + projectID, + }) as Session.Info + const row = Session.toRow(info) + Database.use((db) => + db + .insert(SessionTable) + .values(row) + .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .run(), + ) + + for (const msg of exportData.messages) { + const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info + const { id, sessionID: _, ...msgData } = msgInfo + Database.use((db) => + db + .insert(MessageTable) + .values({ + id, + session_id: row.id, + time_created: msgInfo.time?.created ?? Date.now(), + data: msgData, }) + .onConflictDoNothing() + .run(), + ) - if (!response.ok && dataPath !== `/api/share/${slug}/data`) { - response = await fetch(`${baseUrl}/api/share/${slug}/data`, { - headers, - }) - } - - if (!response.ok) { - process.stdout.write(`Failed to fetch share data: ${response.statusText}`) - process.stdout.write(EOL) - return - } - - const shareData: ShareData[] = await response.json() - const transformed = transformShareData(shareData) - - if (!transformed) { - process.stdout.write(`Share not found or empty: ${slug}`) - process.stdout.write(EOL) - return - } - - exportData = transformed - } else { - exportData = await Filesystem.readJson>(args.file).catch(() => undefined) - if (!exportData) { - process.stdout.write(`File not found: ${args.file}`) - process.stdout.write(EOL) - return - } - } - - if (!exportData) { - process.stdout.write(`Failed to read session data`) - process.stdout.write(EOL) - return - } - - const info = Schema.decodeUnknownSync(Session.Info)({ - ...exportData.info, - projectID: Instance.project.id, - }) as Session.Info - const row = Session.toRow(info) + for (const part of msg.parts) { + const partInfo = decodePart(part) as MessageV2.Part + const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db - .insert(SessionTable) - .values(row) - .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .insert(PartTable) + .values({ + id: partId, + message_id: messageID, + session_id: row.id, + data: partData, + }) + .onConflictDoNothing() .run(), ) + } + } - for (const msg of exportData.messages) { - const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info - const { id, sessionID: _, ...msgData } = msgInfo - Database.use((db) => - db - .insert(MessageTable) - .values({ - id, - session_id: row.id, - time_created: msgInfo.time?.created ?? Date.now(), - data: msgData, - }) - .onConflictDoNothing() - .run(), - ) - - for (const part of msg.parts) { - const partInfo = decodePart(part) as MessageV2.Part - const { id: partId, sessionID: _s, messageID, ...partData } = partInfo - Database.use((db) => - db - .insert(PartTable) - .values({ - id: partId, - message_id: messageID, - session_id: row.id, - data: partData, - }) - .onConflictDoNothing() - .run(), - ) - } - } - - process.stdout.write(`Imported session: ${exportData.info.id}`) - process.stdout.write(EOL) - }) - }, + process.stdout.write(`Imported session: ${exportData.info.id}`) + process.stdout.write(EOL) }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d244549fff..2ae7cece6a 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,4 +1,6 @@ import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" @@ -9,7 +11,7 @@ import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -17,7 +19,6 @@ import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" import { Bus } from "../../bus" -import { AppRuntime } from "../../effect/app-runtime" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -64,35 +65,31 @@ function oauthServers(config: Config.Info) { ) } -async function listState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const statuses = yield* mcp.status() - const stored = yield* Effect.all( - Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), - { concurrency: "unbounded" }, - ) - return { config, statuses, stored } - }), - ) +function listState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const statuses = yield* mcp.status() + const stored = yield* Effect.all( + Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), + { concurrency: "unbounded" }, + ) + return { config, statuses, stored } + }) } -async function authState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const auth = yield* Effect.all( - Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), - { concurrency: "unbounded" }, - ) - return { config, auth } - }), - ) +function authState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const auth = yield* Effect.all( + Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), + { concurrency: "unbounded" }, + ) + return { config, auth } + }) } export const McpCommand = cmd({ @@ -109,73 +106,68 @@ export const McpCommand = cmd({ async handler() {}, }) -export const McpListCommand = cmd({ +export const McpListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list MCP servers and their status", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP Servers") + handler: Effect.fn("Cli.mcp.list")(function* () { + UI.empty() + prompts.intro("MCP Servers") - const { config, statuses, stored } = await listState() - const servers = configuredServers(config) + const { config, statuses, stored } = yield* listState() + const servers = configuredServers(config) - if (servers.length === 0) { - prompts.log.warn("No MCP servers configured") - prompts.outro("Add servers with: opencode mcp add") - return + if (servers.length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Add servers with: opencode mcp add") + return + } + + for (const [name, serverConfig] of servers) { + const status = statuses[name] + const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth + const hasStoredTokens = stored[name] + + let statusIcon: string + let statusText: string + let hint = "" + + if (!status) { + statusIcon = "○" + statusText = "not initialized" + } else if (status.status === "connected") { + statusIcon = "✓" + statusText = "connected" + if (hasOAuth && hasStoredTokens) { + hint = " (OAuth)" } + } else if (status.status === "disabled") { + statusIcon = "○" + statusText = "disabled" + } else if (status.status === "needs_auth") { + statusIcon = "⚠" + statusText = "needs authentication" + } else if (status.status === "needs_client_registration") { + statusIcon = "✗" + statusText = "needs client registration" + hint = "\n " + status.error + } else { + statusIcon = "✗" + statusText = "failed" + hint = "\n " + status.error + } - for (const [name, serverConfig] of servers) { - const status = statuses[name] - const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth - const hasStoredTokens = stored[name] + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + prompts.log.info( + `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, + ) + } - let statusIcon: string - let statusText: string - let hint = "" - - if (!status) { - statusIcon = "○" - statusText = "not initialized" - } else if (status.status === "connected") { - statusIcon = "✓" - statusText = "connected" - if (hasOAuth && hasStoredTokens) { - hint = " (OAuth)" - } - } else if (status.status === "disabled") { - statusIcon = "○" - statusText = "disabled" - } else if (status.status === "needs_auth") { - statusIcon = "⚠" - statusText = "needs authentication" - } else if (status.status === "needs_client_registration") { - statusIcon = "✗" - statusText = "needs client registration" - hint = "\n " + status.error - } else { - statusIcon = "✗" - statusText = "failed" - hint = "\n " + status.error - } - - const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") - prompts.log.info( - `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, - ) - } - - prompts.outro(`${servers.length} server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} server(s)`) + }), }) -export const McpAuthCommand = cmd({ +export const McpAuthCommand = effectCmd({ command: "auth [name]", describe: "authenticate with an OAuth-enabled MCP server", builder: (yargs) => @@ -185,98 +177,98 @@ export const McpAuthCommand = cmd({ type: "string", }) .command(McpAuthListCommand), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Authentication") + handler: Effect.fn("Cli.mcp.auth")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Authentication") - const { config, auth } = await authState() - const mcpServers = config.mcp ?? {} - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const mcpServers = config.mcp ?? {} + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") - prompts.log.info(` + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") + prompts.log.info(` "mcp": { "my-server": { "type": "remote", "url": "https://example.com/mcp" } }`) - prompts.outro("Done") - return + prompts.outro("Done") + return + } + + let serverName = args.name + if (!serverName) { + // Build options with auth status + const options = servers.map(([name, cfg]) => { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = cfg.url + return { + label: `${icon} ${name} (${statusText})`, + value: name, + hint: url, } + }) - let serverName = args.name - if (!serverName) { - // Build options with auth status - const options = servers.map(([name, cfg]) => { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = cfg.url - return { - label: `${icon} ${name} (${statusText})`, - value: name, - hint: url, - } - }) + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to authenticate", + options, + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - const selected = await prompts.select({ - message: "Select MCP server to authenticate", - options, - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { + prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) + prompts.outro("Done") + return + } - if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { - prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) - prompts.outro("Done") - return - } + // Check if already authenticated + const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))) + if (authStatus === "authenticated") { + const confirm = yield* Effect.promise(() => + prompts.confirm({ + message: `${serverName} already has valid credentials. Re-authenticate?`, + }), + ) + if (prompts.isCancel(confirm) || !confirm) { + prompts.outro("Cancelled") + return + } + } else if (authStatus === "expired") { + prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) + } - // Check if already authenticated - const authStatus = - auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))) - if (authStatus === "authenticated") { - const confirm = await prompts.confirm({ - message: `${serverName} already has valid credentials. Re-authenticate?`, - }) - if (prompts.isCancel(confirm) || !confirm) { - prompts.outro("Cancelled") - return - } - } else if (authStatus === "expired") { - prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) - } + const spinner = prompts.spinner() + spinner.start("Starting OAuth flow...") - const spinner = prompts.spinner() - spinner.start("Starting OAuth flow...") - - // Subscribe to browser open failure events to show URL for manual opening - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - if (evt.properties.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(evt.properties.url) - spinner.start("Waiting for authorization...") - } - }) - - try { - const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) + // Subscribe to browser open failure events to show URL for manual opening + const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { + if (evt.properties.mcpName === serverName) { + spinner.stop("Could not open browser automatically") + prompts.log.warn("Please open this URL in your browser to authenticate:") + prompts.log.info(evt.properties.url) + spinner.start("Waiting for authorization...") + } + }) + yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + Effect.tap((status) => + Effect.sync(() => { if (status.status === "connected") { spinner.stop("Authentication successful!") } else if (status.status === "needs_client_registration") { @@ -300,55 +292,53 @@ export const McpAuthCommand = cmd({ } else { spinner.stop("Unexpected status: " + status.status, 1) } - } catch (error) { + }), + ), + Effect.catchCause((cause) => + Effect.sync(() => { spinner.stop("Authentication failed", 1) + const error = Cause.squash(cause) prompts.log.error(error instanceof Error ? error.message : String(error)) - } finally { - unsubscribe() - } + }), + ), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ) - prompts.outro("Done") - }, - }) - }, + prompts.outro("Done") + }), }) -export const McpAuthListCommand = cmd({ +export const McpAuthListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Status") + handler: Effect.fn("Cli.mcp.auth.list")(function* () { + UI.empty() + prompts.intro("MCP OAuth Status") - const { config, auth } = await authState() - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.outro("Done") - return - } + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.outro("Done") + return + } - for (const [name, serverConfig] of servers) { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = serverConfig.url + for (const [name, serverConfig] of servers) { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = serverConfig.url - prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) - } + prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) + } - prompts.outro(`${servers.length} OAuth-capable server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} OAuth-capable server(s)`) + }), }) -export const McpLogoutCommand = cmd({ +export const McpLogoutCommand = effectCmd({ command: "logout [name]", describe: "remove OAuth credentials for an MCP server", builder: (yargs) => @@ -356,57 +346,54 @@ export const McpLogoutCommand = cmd({ describe: "name of the MCP server", type: "string", }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Logout") + handler: Effect.fn("Cli.mcp.logout")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Logout") - const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) - const serverNames = Object.keys(credentials) + const credentials = yield* McpAuth.Service.use((auth) => auth.all()) + const serverNames = Object.keys(credentials) - if (serverNames.length === 0) { - prompts.log.warn("No MCP OAuth credentials stored") - prompts.outro("Done") - return - } + if (serverNames.length === 0) { + prompts.log.warn("No MCP OAuth credentials stored") + prompts.outro("Done") + return + } - let serverName = args.name - if (!serverName) { - const selected = await prompts.select({ - message: "Select MCP server to logout", - options: serverNames.map((name) => { - const entry = credentials[name] - const hasTokens = !!entry.tokens - const hasClient = !!entry.clientInfo - let hint = "" - if (hasTokens && hasClient) hint = "tokens + client" - else if (hasTokens) hint = "tokens" - else if (hasClient) hint = "client registration" - return { - label: name, - value: name, - hint, - } - }), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + let serverName = args.name + if (!serverName) { + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to logout", + options: serverNames.map((name) => { + const entry = credentials[name] + const hasTokens = !!entry.tokens + const hasClient = !!entry.clientInfo + let hint = "" + if (hasTokens && hasClient) hint = "tokens + client" + else if (hasTokens) hint = "tokens" + else if (hasClient) hint = "client registration" + return { + label: name, + value: name, + hint, + } + }), + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - if (!credentials[serverName]) { - prompts.log.error(`No credentials found for: ${serverName}`) - prompts.outro("Done") - return - } + if (!credentials[serverName]) { + prompts.log.error(`No credentials found for: ${serverName}`) + prompts.outro("Done") + return + } - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) - prompts.log.success(`Removed OAuth credentials for ${serverName}`) - prompts.outro("Done") - }, - }) - }, + yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName)) + prompts.log.success(`Removed OAuth credentials for ${serverName}`) + prompts.outro("Done") + }), }) async function resolveConfigPath(baseDir: string, global = false) { @@ -444,171 +431,171 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat return configPath } -export const McpAddCommand = cmd({ +export const McpAddCommand = effectCmd({ command: "add", describe: "add an MCP server", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("Add MCP server") + handler: Effect.fn("Cli.mcp.add")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("Add MCP server") - const project = Instance.project + const project = ctx.project - // Resolve config paths eagerly for hints - const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(Instance.worktree), - resolveConfigPath(Global.Path.config, true), - ]) + // Resolve config paths eagerly for hints + const [projectConfigPath, globalConfigPath] = await Promise.all([ + resolveConfigPath(ctx.worktree), + resolveConfigPath(Global.Path.config, true), + ]) - // Determine scope - let configPath = globalConfigPath - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: projectConfigPath, - hint: projectConfigPath, - }, - { - label: "Global", - value: globalConfigPath, - hint: globalConfigPath, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult - } - - const name = await prompts.text({ - message: "Enter MCP server name", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(name)) throw new UI.CancelledError() - - const type = await prompts.select({ - message: "Select MCP server type", + // Determine scope + let configPath = globalConfigPath + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "Local", - value: "local", - hint: "Run a local command", + label: "Current project", + value: projectConfigPath, + hint: projectConfigPath, }, { - label: "Remote", - value: "remote", - hint: "Connect to a remote URL", + label: "Global", + value: globalConfigPath, + hint: globalConfigPath, }, ], }) - if (prompts.isCancel(type)) throw new UI.CancelledError() + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + configPath = scopeResult + } - if (type === "local") { - const command = await prompts.text({ - message: "Enter command to run", - placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(command)) throw new UI.CancelledError() + const name = await prompts.text({ + message: "Enter MCP server name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(name)) throw new UI.CancelledError() - const mcpConfig: ConfigMCP.Info = { - type: "local", - command: command.split(" "), - } + const type = await prompts.select({ + message: "Select MCP server type", + options: [ + { + label: "Local", + value: "local", + hint: "Run a local command", + }, + { + label: "Remote", + value: "remote", + hint: "Connect to a remote URL", + }, + ], + }) + if (prompts.isCancel(type)) throw new UI.CancelledError() - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) - prompts.outro("MCP server added successfully") - return + if (type === "local") { + const command = await prompts.text({ + message: "Enter command to run", + placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(command)) throw new UI.CancelledError() + + const mcpConfig: ConfigMCP.Info = { + type: "local", + command: command.split(" "), } - if (type === "remote") { - const url = await prompts.text({ - message: "Enter MCP server URL", - placeholder: "e.g., https://example.com/mcp", - validate: (x) => { - if (!x) return "Required" - if (x.length === 0) return "Required" - const isValid = URL.canParse(x) - return isValid ? undefined : "Invalid URL" - }, - }) - if (prompts.isCancel(url)) throw new UI.CancelledError() + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + prompts.outro("MCP server added successfully") + return + } - const useOAuth = await prompts.confirm({ - message: "Does this server require OAuth authentication?", + if (type === "remote") { + const url = await prompts.text({ + message: "Enter MCP server URL", + placeholder: "e.g., https://example.com/mcp", + validate: (x) => { + if (!x) return "Required" + if (x.length === 0) return "Required" + const isValid = URL.canParse(x) + return isValid ? undefined : "Invalid URL" + }, + }) + if (prompts.isCancel(url)) throw new UI.CancelledError() + + const useOAuth = await prompts.confirm({ + message: "Does this server require OAuth authentication?", + initialValue: false, + }) + if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + + let mcpConfig: ConfigMCP.Info + + if (useOAuth) { + const hasClientId = await prompts.confirm({ + message: "Do you have a pre-registered client ID?", initialValue: false, }) - if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() - let mcpConfig: ConfigMCP.Info + if (hasClientId) { + const clientId = await prompts.text({ + message: "Enter client ID", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(clientId)) throw new UI.CancelledError() - if (useOAuth) { - const hasClientId = await prompts.confirm({ - message: "Do you have a pre-registered client ID?", + const hasSecret = await prompts.confirm({ + message: "Do you have a client secret?", initialValue: false, }) - if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() + if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - if (hasClientId) { - const clientId = await prompts.text({ - message: "Enter client ID", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), + let clientSecret: string | undefined + if (hasSecret) { + const secret = await prompts.password({ + message: "Enter client secret", }) - if (prompts.isCancel(clientId)) throw new UI.CancelledError() + if (prompts.isCancel(secret)) throw new UI.CancelledError() + clientSecret = secret + } - const hasSecret = await prompts.confirm({ - message: "Do you have a client secret?", - initialValue: false, - }) - if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - - let clientSecret: string | undefined - if (hasSecret) { - const secret = await prompts.password({ - message: "Enter client secret", - }) - if (prompts.isCancel(secret)) throw new UI.CancelledError() - clientSecret = secret - } - - mcpConfig = { - type: "remote", - url, - oauth: { - clientId, - ...(clientSecret && { clientSecret }), - }, - } - } else { - mcpConfig = { - type: "remote", - url, - oauth: {}, - } + mcpConfig = { + type: "remote", + url, + oauth: { + clientId, + ...(clientSecret && { clientSecret }), + }, } } else { mcpConfig = { type: "remote", url, + oauth: {}, } } - - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } else { + mcpConfig = { + type: "remote", + url, + } } - prompts.outro("MCP server added successfully") - }, + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } + + prompts.outro("MCP server added successfully") }) - }, + }), }) -export const McpDebugCommand = cmd({ +export const McpDebugCommand = effectCmd({ command: "debug ", describe: "debug OAuth connection for an MCP server", builder: (yargs) => @@ -617,182 +604,172 @@ export const McpDebugCommand = cmd({ type: "string", demandOption: true, }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Debug") + handler: Effect.fn("Cli.mcp.debug")(function* (args) { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("MCP OAuth Debug") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} - const serverName = args.name + const mcpServers = config.mcp ?? {} + const serverName = args.name - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } + + if (!isMcpRemote(serverConfig)) { + prompts.log.error(`MCP server ${serverName} is not a remote server`) + prompts.outro("Done") + return + } + + if (serverConfig.oauth === false) { + prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) + prompts.outro("Done") + return + } + + prompts.log.info(`Server: ${serverName}`) + prompts.log.info(`URL: ${serverConfig.url}`) + + // Check stored auth status — services already in hand, run inline. + const { authStatus, entry } = await Effect.runPromise( + Effect.all({ + authStatus: mcp.getAuthStatus(serverName), + entry: auth.get(serverName), + }), + ) + prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) + + if (entry?.tokens) { + prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + if (entry.tokens.expiresAt) { + const expiresDate = new Date(entry.tokens.expiresAt * 1000) + const isExpired = entry.tokens.expiresAt < Date.now() / 1000 + prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) } - - if (!isMcpRemote(serverConfig)) { - prompts.log.error(`MCP server ${serverName} is not a remote server`) - prompts.outro("Done") - return + if (entry.tokens.refreshToken) { + prompts.log.info(` Refresh token: present`) } - - if (serverConfig.oauth === false) { - prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) - prompts.outro("Done") - return + } + if (entry?.clientInfo) { + prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) + if (entry.clientInfo.clientSecretExpiresAt) { + const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) + prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) } + } - prompts.log.info(`Server: ${serverName}`) - prompts.log.info(`URL: ${serverConfig.url}`) + const spinner = prompts.spinner() + spinner.start("Testing connection...") - // Check stored auth status - const { authStatus, entry } = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - return { - authStatus: yield* mcp.getAuthStatus(serverName), - entry: yield* auth.get(serverName), - } - }), - ) - prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - - if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) - if (entry.tokens.expiresAt) { - const expiresDate = new Date(entry.tokens.expiresAt * 1000) - const isExpired = entry.tokens.expiresAt < Date.now() / 1000 - prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) - } - if (entry.tokens.refreshToken) { - prompts.log.info(` Refresh token: present`) - } - } - if (entry?.clientInfo) { - prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) - if (entry.clientInfo.clientSecretExpiresAt) { - const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) - prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) - } - } - - const spinner = prompts.spinner() - spinner.start("Testing connection...") - - // Test basic HTTP connectivity first - try { - const response = await fetch(serverConfig.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", + // Test basic HTTP connectivity first + try { + const response = await fetch(serverConfig.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "opencode-debug", version: InstallationVersion }, - }, - id: 1, - }), + id: 1, + }), + }) + + spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) + + // Check for WWW-Authenticate header + const wwwAuth = response.headers.get("www-authenticate") + if (wwwAuth) { + prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) + } + + if (response.status === 401) { + prompts.log.warn("Server returned 401 Unauthorized") + + // Try to discover OAuth metadata + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) + + prompts.log.info("Testing OAuth flow (without completing authorization)...") + + // Try creating transport with auth provider to trigger discovery + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, }) - spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) - - // Check for WWW-Authenticate header - const wwwAuth = response.headers.get("www-authenticate") - if (wwwAuth) { - prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) - } - - if (response.status === 401) { - prompts.log.warn("Server returned 401 Unauthorized") - - // Try to discover OAuth metadata - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const auth = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* McpAuth.Service - }), - ) - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async () => {}, - }, - auth, - ) - - prompts.log.info("Testing OAuth flow (without completing authorization)...") - - // Try creating transport with auth provider to trigger discovery - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, + try { + const client = new Client({ + name: "opencode-debug", + version: InstallationVersion, }) + await client.connect(transport) + prompts.log.success("Connection successful (already authenticated)") + await client.close() + } catch (error) { + if (error instanceof UnauthorizedError) { + prompts.log.info(`OAuth flow triggered: ${error.message}`) - try { - const client = new Client({ - name: "opencode-debug", - version: InstallationVersion, - }) - await client.connect(transport) - prompts.log.success("Connection successful (already authenticated)") - await client.close() - } catch (error) { - if (error instanceof UnauthorizedError) { - prompts.log.info(`OAuth flow triggered: ${error.message}`) - - // Check if dynamic registration would be attempted - const clientInfo = await authProvider.clientInformation() - if (clientInfo) { - prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - } else { - prompts.log.info("No client ID - dynamic registration will be attempted") - } + // Check if dynamic registration would be attempted + const clientInfo = await authProvider.clientInformation() + if (clientInfo) { + prompts.log.info(`Client ID available: ${clientInfo.client_id}`) } else { - prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) + prompts.log.info("No client ID - dynamic registration will be attempted") } - } - } else if (response.status >= 200 && response.status < 300) { - prompts.log.success("Server responded successfully (no auth required or already authenticated)") - const body = await response.text() - try { - const json = JSON.parse(body) - if (json.result?.serverInfo) { - prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) - } - } catch { - // Not JSON, ignore - } - } else { - prompts.log.warn(`Unexpected status: ${response.status}`) - const body = await response.text().catch(() => "") - if (body) { - prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } else { + prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) } } - } catch (error) { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } else if (response.status >= 200 && response.status < 300) { + prompts.log.success("Server responded successfully (no auth required or already authenticated)") + const body = await response.text() + try { + const json = JSON.parse(body) + if (json.result?.serverInfo) { + prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) + } + } catch { + // Not JSON, ignore + } + } else { + prompts.log.warn(`Unexpected status: ${response.status}`) + const body = await response.text().catch(() => "") + if (body) { + prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } } + } catch (error) { + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } - prompts.outro("Debug complete") - }, + prompts.outro("Debug complete") }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 1ac0b071dd..1529e9b71d 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -1,16 +1,16 @@ import { intro, log, outro, spinner } from "@clack/prompts" -import type { Argv } from "yargs" +import { Effect } from "effect" import { ConfigPaths } from "@/config/paths" import { Global } from "@opencode-ai/core/global" import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install" import { resolvePluginTarget } from "../../plugin/shared" -import { Instance } from "../../project/instance" import { errorMessage } from "../../util/error" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" type Spin = { start: (msg: string) => void @@ -175,12 +175,12 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps } } -export const PluginCommand = cmd({ +export const PluginCommand = effectCmd({ command: "plugin ", aliases: ["plug"], describe: "install plugin and update config", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("module", { type: "string", describe: "npm module name", @@ -196,9 +196,8 @@ export const PluginCommand = cmd({ type: "boolean", default: false, describe: "replace existing plugin version", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.plug")(function* (args) { const mod = String(args.module ?? "").trim() if (!mod) { UI.error("module is required") @@ -214,20 +213,18 @@ export const PluginCommand = cmd({ global: Boolean(args.global), force: Boolean(args.force), }) - let ok = true - await Instance.provide({ - directory: process.cwd(), - fn: async () => { - ok = await run({ - vcs: Instance.project.vcs, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - }) + const ctx = yield* InstanceRef + if (!ctx) return + const ok = yield* Effect.promise(() => + run({ + vcs: ctx.project.vcs, + worktree: ctx.worktree, + directory: ctx.directory, + }), + ) outro("Done") if (!ok) process.exitCode = 1 - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index f392bab4c8..4209722357 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -1,11 +1,11 @@ +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd, fail } from "../effect-cmd" import { Git } from "@/git" -import { Instance } from "@/project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { Process } from "@/util/process" -export const PrCommand = cmd({ +export const PrCommand = effectCmd({ command: "pr ", describe: "fetch and checkout a GitHub PR branch, then run opencode", builder: (yargs) => @@ -14,125 +14,102 @@ export const PrCommand = cmd({ describe: "PR number to checkout", demandOption: true, }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - const project = Instance.project - if (project.vcs !== "git") { - UI.error("Could not find git repository. Please run this command from a git repository.") - process.exit(1) + handler: Effect.fn("Cli.pr")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* fail("Could not load instance context") + if (ctx.project.vcs !== "git") { + return yield* fail("Could not find git repository. Please run this command from a git repository.") + } + + const git = yield* Git.Service + const worktree = ctx.worktree + + const prNumber = args.number + const localBranchName = `pr/${prNumber}` + UI.println(`Fetching and checking out PR #${prNumber}...`) + + const checkout = yield* Effect.promise(() => + Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }), + ) + if (checkout.code !== 0) { + return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) + } + + const prInfoResult = yield* Effect.promise(() => + Process.text( + [ + "gh", + "pr", + "view", + `${prNumber}`, + "--json", + "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", + ], + { nothrow: true }, + ), + ) + + let sessionId: string | undefined + + if (prInfoResult.code === 0 && prInfoResult.text.trim()) { + const prInfo = JSON.parse(prInfoResult.text) + + if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) { + const forkOwner = prInfo.headRepositoryOwner.login + const forkName = prInfo.headRepository.name + const remoteName = forkOwner + + const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim() + if (!remotes.split("\n").includes(remoteName)) { + yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + cwd: worktree, + }) + UI.println(`Added fork remote: ${remoteName}`) } - const prNumber = args.number - const localBranchName = `pr/${prNumber}` - UI.println(`Fetching and checking out PR #${prNumber}...`) + yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], { + cwd: worktree, + }) + } - // Use gh pr checkout with custom branch name - const result = await Process.run( - ["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], - { - nothrow: true, - }, - ) + if (prInfo?.body) { + const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) + if (sessionMatch) { + const sessionUrl = sessionMatch[0] + UI.println(`Found opencode session: ${sessionUrl}`) + UI.println(`Importing session...`) - if (result.code !== 0) { - UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) - process.exit(1) - } - - // Fetch PR info for fork handling and session link detection - const prInfoResult = await Process.text( - [ - "gh", - "pr", - "view", - `${prNumber}`, - "--json", - "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", - ], - { nothrow: true }, - ) - - let sessionId: string | undefined - - if (prInfoResult.code === 0) { - const prInfoText = prInfoResult.text - if (prInfoText.trim()) { - const prInfo = JSON.parse(prInfoText) - - // Handle fork PRs - if (prInfo && prInfo.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) { - const forkOwner = prInfo.headRepositoryOwner.login - const forkName = prInfo.headRepository.name - const remoteName = forkOwner - - // Check if remote already exists - const remotes = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })), - ).then((x) => x.text().trim()) - if (!remotes.split("\n").includes(remoteName)) { - await AppRuntime.runPromise( - Git.Service.use((git) => - git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { - cwd: Instance.worktree, - }), - ), - ) - UI.println(`Added fork remote: ${remoteName}`) - } - - // Set upstream to the fork so pushes go there - const headRefName = prInfo.headRefName - await AppRuntime.runPromise( - Git.Service.use((git) => - git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { - cwd: Instance.worktree, - }), - ), - ) - } - - // Check for opencode session link in PR body - if (prInfo && prInfo.body) { - const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) - if (sessionMatch) { - const sessionUrl = sessionMatch[0] - UI.println(`Found opencode session: ${sessionUrl}`) - UI.println(`Importing session...`) - - const importResult = await Process.text(["opencode", "import", sessionUrl], { - nothrow: true, - }) - if (importResult.code === 0) { - const importOutput = importResult.text.trim() - // Extract session ID from the output (format: "Imported session: ") - const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/) - if (sessionIdMatch) { - sessionId = sessionIdMatch[1] - UI.println(`Session imported: ${sessionId}`) - } - } - } + const importResult = yield* Effect.promise(() => + Process.text(["opencode", "import", sessionUrl], { nothrow: true }), + ) + if (importResult.code === 0) { + const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/) + if (sessionIdMatch) { + sessionId = sessionIdMatch[1] + UI.println(`Session imported: ${sessionId}`) } } } + } + } - UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) - UI.println() - UI.println("Starting opencode...") - UI.println() + UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) + UI.println() + UI.println("Starting opencode...") + UI.println() - const opencodeArgs = sessionId ? ["-s", sessionId] : [] - const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], { + const opencodeArgs = sessionId ? ["-s", sessionId] : [] + const code = yield* Effect.promise( + () => + Process.spawn(["opencode", ...opencodeArgs], { stdin: "inherit", stdout: "inherit", stderr: "inherit", cwd: process.cwd(), - }) - const code = await opencodeProcess.exited - if (code !== 0) throw new Error(`opencode exited with code ${code}`) - }, - }) - }, + }).exited, + ) + // Match legacy throw semantics — propagate as a defect so the top-level + // index.ts catch handles it identically (exit 1, "Unexpected error" banner). + if (code !== 0) return yield* Effect.die(new Error(`opencode exited with code ${code}`)) + }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 12245efcf4..ada015593b 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,59 +1,69 @@ import { Auth } from "../../auth" -import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" -import * as prompts from "@clack/prompts" +import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" +import * as Prompt from "../effect/prompt" import { ModelsDev } from "@/provider/models" -const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())) -const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true))) import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { Instance } from "../../project/instance" import type { Hooks } from "@kilocode/plugin" import { Process } from "@/util/process" +import { errorMessage } from "@/util/error" import { text } from "node:stream/consumers" -import { Effect } from "effect" +import { Effect, Option } from "effect" type PluginAuth = NonNullable -const put = (key: string, info: Auth.Info) => - AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set(key, info) - }), - ) +const promptValue = (value: Option.Option) => { + if (Option.isNone(value)) return Effect.die(new UI.CancelledError()) + return Effect.succeed(value.value) +} -async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise { - let index = 0 - if (methodName) { +const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) { + const auth = yield* Auth.Service + yield* Effect.orDie(auth.set(key, info)) +}) + +const cliTry = (message: string, fn: () => PromiseLike) => + Effect.tryPromise({ + try: fn, + catch: (error) => new CliError({ message: message + errorMessage(error) }), + }) + +const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( + plugin: { auth: PluginAuth }, + provider: string, + methodName?: string, +) { + const index = yield* Effect.gen(function* () { + if (!methodName) { + if (plugin.auth.methods.length <= 1) return 0 + return yield* promptValue( + yield* Prompt.select({ + message: "Login method", + options: plugin.auth.methods.map((x, index) => ({ + label: x.label, + value: index, + })), + }), + ) + } const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase()) if (match === -1) { - prompts.log.error( + return yield* fail( `Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`, ) - process.exit(1) } - index = match - } else if (plugin.auth.methods.length > 1) { - const method = await prompts.select({ - message: "Login method", - options: plugin.auth.methods.map((x, index) => ({ - label: x.label, - value: index.toString(), - })), - }) - if (prompts.isCancel(method)) throw new UI.CancelledError() - index = parseInt(method) - } + return match + }) const method = plugin.auth.methods[index] - await new Promise((r) => setTimeout(r, 10)) + yield* Effect.sleep("10 millis") const inputs: Record = {} if (method.prompts) { for (const prompt of method.prompts) { @@ -65,46 +75,44 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (prompt.condition && !prompt.condition(inputs)) continue if (prompt.type === "select") { - const value = await prompts.select({ + const value = yield* Prompt.select({ message: prompt.message, options: prompt.options, }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } else { - const value = await prompts.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value + inputs[prompt.key] = yield* promptValue(value) + continue } + const value = yield* Prompt.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, + }) + inputs[prompt.key] = yield* promptValue(value) } } if (method.type === "oauth") { - const authorize = await method.authorize(inputs) + const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs)) if (authorize.url) { - prompts.log.info("Go to: " + authorize.url) + yield* Prompt.log.info("Go to: " + authorize.url) } if (authorize.method === "auto") { if (authorize.instructions) { - prompts.log.info(authorize.instructions) + yield* Prompt.log.info(authorize.instructions) } - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - const result = await authorize.callback() + const spinner = Prompt.spinner() + yield* spinner.start("Waiting for authorization...") + const result = yield* cliTry("Failed to authorize: ", () => authorize.callback()) if (result.type === "failed") { - spinner.stop("Failed to authorize", 1) + yield* spinner.stop("Failed to authorize", 1) } if (result.type === "success") { const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await put(saveProvider, { + yield* put(saveProvider, { type: "oauth", refresh, access, @@ -113,30 +121,30 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await put(saveProvider, { + yield* put(saveProvider, { type: "api", key: result.key, }) } - spinner.stop("Login successful") + yield* spinner.stop("Login successful") } } if (authorize.method === "code") { - const code = await prompts.text({ + const code = yield* Prompt.text({ message: "Paste the authorization code here: ", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(code)) throw new UI.CancelledError() - const result = await authorize.callback(code) + const authorizationCode = yield* promptValue(code) + const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode)) if (result.type === "failed") { - prompts.log.error("Failed to authorize") + yield* Prompt.log.error("Failed to authorize") } if (result.type === "success") { const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await put(saveProvider, { + yield* put(saveProvider, { type: "oauth", refresh, access, @@ -145,56 +153,57 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await put(saveProvider, { + yield* put(saveProvider, { type: "api", key: result.key, }) } - prompts.log.success("Login successful") + yield* Prompt.log.success("Login successful") } } - prompts.outro("Done") + yield* Prompt.outro("Done") return true } if (method.type === "api") { - const key = await prompts.password({ + const key = yield* Prompt.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(key)) throw new UI.CancelledError() + const apiKey = yield* promptValue(key) const metadata = Object.keys(inputs).length ? { metadata: inputs } : {} - if (!method.authorize) { - await put(provider, { + const authorizeApi = method.authorize + if (!authorizeApi) { + yield* put(provider, { type: "api", - key, + key: apiKey, ...metadata, }) - prompts.outro("Done") + yield* Prompt.outro("Done") return true } - const result = await method.authorize(inputs) + const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs)) if (result.type === "failed") { - prompts.log.error("Failed to authorize") + yield* Prompt.log.error("Failed to authorize") } if (result.type === "success") { const saveProvider = result.provider ?? provider - await put(saveProvider, { + yield* put(saveProvider, { type: "api", - key: result.key ?? key, + key: result.key ?? apiKey, ...metadata, }) - prompts.log.success("Login successful") + yield* Prompt.log.success("Login successful") } - prompts.outro("Done") + yield* Prompt.outro("Done") return true } return false -} +}) export function resolvePluginProviders(input: { hooks: Hooks[] @@ -232,30 +241,30 @@ export const ProvidersCommand = cmd({ async handler() {}, }) -export const ProvidersListCommand = cmd({ +export const ProvidersListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list providers and credentials", - async handler(_args) { + // Lists global credentials + provider env vars; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.list")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - const database = await getModels() + yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const results = Object.entries(yield* Effect.orDie(authSvc.all())) + const database = yield* modelsDev.get() for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) } - prompts.outro(`${results.length} credentials`) + yield* Prompt.outro(`${results.length} credentials`) const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -272,18 +281,18 @@ export const ProvidersListCommand = cmd({ if (activeEnvVars.length > 0) { UI.empty() - prompts.intro("Environment") + yield* Prompt.intro("Environment") for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) } - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) } - }, + }), }) -export const ProvidersLoginCommand = cmd({ +export const ProvidersLoginCommand = effectCmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => @@ -302,228 +311,202 @@ export const ProvidersLoginCommand = cmd({ describe: "login method label (skips method selection)", type: "string", }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { - auth: { command: string[]; env: string } - } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await put(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) - prompts.outro("Done") - return - } - await refreshModels().catch(() => {}) + handler: Effect.fn("Cli.providers.login")(function* (args) { + const authSvc = yield* Auth.Service - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const providers = await getModels().then((x) => { - const filtered: Record = {} - for (const [key, value] of Object.entries(x)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - return filtered - }) - const hooks = await AppRuntime.runPromise( - Effect.gen(function* () { - const plugin = yield* Plugin.Service - return yield* plugin.list() - }), - ) - - const priority: Record = { - opencode: 0, - openai: 1, - "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, - } - const pluginProviders = resolvePluginProviders({ - hooks, - existingProviders: providers, - disabled, - enabled, - providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), - }) - const options = [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), - ), - ...pluginProviders.map((x) => ({ - label: x.name, - value: x.id, - hint: "plugin", - })), - ] - - let provider: string - if (args.provider) { - const input = args.provider - const byID = options.find((x) => x.value === input) - const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName - if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) - } - provider = match.value - } else { - const selected = await prompts.autocomplete({ - message: "Select provider", - maxItems: 8, - options: [ - ...options, - { - value: "other", - label: "Other", - }, - ], - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - provider = selected as string - } - - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) - if (handled) return - } - - if (provider === "other") { - const custom = await prompts.text({ - message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), - }) - if (prompts.isCancel(custom)) throw new UI.CancelledError() - provider = custom.replace(/^@ai-sdk\//, "") - - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) - if (handled) return - } - - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, - ) - } - - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", - ) - } - - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } - - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } - - if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( - "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", - ) - } - - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await put(provider, { - type: "api", - key, - }) - - prompts.outro("Done") - }, - }) - }, -}) - -export const ProvidersLogoutCommand = cmd({ - command: "logout", - describe: "log out from a configured provider", - async handler(_args) { UI.empty() - const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") + yield* Prompt.intro("Add credential") + if (args.url) { + const url = args.url.replace(/\/+$/, "") + const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () => + fetch(`${url}/.well-known/opencode`).then((x) => x.json()), + )) as { + auth: { command: string[]; env: string } + } + yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const abort = new AbortController() + const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal }) + if (!proc.stdout) { + yield* Prompt.log.error("Failed") + yield* Prompt.outro("Done") + return + } + const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () => + Promise.all([proc.exited, text(proc.stdout!)]), + ).pipe(Effect.ensuring(Effect.sync(() => abort.abort()))) + if (exit !== 0) { + yield* Prompt.log.error("Failed") + yield* Prompt.outro("Done") + return + } + yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() })) + yield* Prompt.log.success("Logged into " + url) + yield* Prompt.outro("Done") return } - const database = await getModels() - const selected = await prompts.select({ + + const cfgSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service + const modelsDev = yield* ModelsDev.Service + yield* Effect.ignore(modelsDev.refresh(true)) + + const config = yield* cfgSvc.get() + + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const allProviders = yield* modelsDev.get() + const providers: Record = {} + for (const [key, value] of Object.entries(allProviders)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value + } + const hooks = yield* pluginSvc.list() + + const priority: Record = { + opencode: 0, + openai: 1, + "github-copilot": 2, + google: 3, + anthropic: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks, + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + const options = [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: { + opencode: "recommended", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], + })), + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + ] + + let provider: string + if (args.provider) { + const input = args.provider + const byID = options.find((x) => x.value === input) + const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) + const match = byID ?? byName + if (!match) { + return yield* fail(`Unknown provider "${input}"`) + } + provider = match.value + } else { + provider = yield* promptValue( + yield* Prompt.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [...options, { value: "other", label: "Other" }], + }), + ) + } + + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method) + if (handled) return + } + + if (provider === "other") { + provider = (yield* promptValue( + yield* Prompt.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }), + )).replace(/^@ai-sdk\//, "") + + const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) + if (customPlugin && customPlugin.auth) { + const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method) + if (handled) return + } + + yield* Prompt.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } + + if (provider === "amazon-bedrock") { + yield* Prompt.log.info( + "Amazon Bedrock authentication priority:\n" + + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } + + if (provider === "opencode") { + yield* Prompt.log.info("Create an api key at https://opencode.ai/auth") + } + + if (provider === "vercel") { + yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + } + + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + yield* Prompt.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } + + const key = yield* Prompt.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + const apiKey = yield* promptValue(key) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey })) + + yield* Prompt.outro("Done") + }), +}) + +export const ProvidersLogoutCommand = effectCmd({ + command: "logout", + describe: "log out from a configured provider", + // Removes a global auth credential; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.logout")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + + UI.empty() + const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + yield* Prompt.intro("Remove credential") + if (credentials.length === 0) { + yield* Prompt.log.error("No credentials found") + return + } + const database = yield* modelsDev.get() + const selected = yield* Prompt.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })), }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - const providerID = selected as string - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) - prompts.outro("Logout successful") - }, + yield* Effect.orDie(authSvc.remove(yield* promptValue(selected))) + yield* Prompt.outro("Logout successful") + }), }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 536f9f7185..e9a7632b89 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,10 +1,11 @@ import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" -import { bootstrap } from "../bootstrap" +import { ServerAuth } from "@/server/auth" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createKiloClient, type KiloClient, type ToolPart } from "@kilocode/sdk/v2" @@ -22,10 +23,10 @@ import { WriteTool } from "../../tool/write" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" +import { ShellTool } from "../../tool/shell" +import { ShellID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" -import { AppRuntime } from "@/effect/app-runtime" type ToolProps = { input: Tool.InferParameters @@ -175,7 +176,7 @@ function skill(info: ToolProps) { }) } -function bash(info: ToolProps) { +function shell(info: ToolProps) { const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined block( { @@ -202,11 +203,17 @@ function normalizePath(input?: string) { return input } -export const RunCommand = cmd({ +export const RunCommand = effectCmd({ command: "run [message..]", describe: "run opencode with a message", - builder: (yargs: Argv) => { - return yargs + // --attach connects to a remote server (no local instance needed); the + // default path runs an in-process server and needs the project instance. + instance: (args) => !args.attach, + // For --dir without --attach, load instance for the resolved target dir. + // The handler also chdirs (preserving the legacy order: chdir → file resolution). + directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()), + builder: (yargs: Argv) => + yargs .positional("message", { describe: "message to send", type: "string", @@ -269,6 +276,11 @@ export const RunCommand = cmd({ type: "string", describe: "basic auth password (defaults to KILO_SERVER_PASSWORD)", }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to KILO_SERVER_USERNAME or 'opencode')", + }) .option("dir", { type: "string", describe: "directory to run in, path on remote server if attaching", @@ -290,291 +302,314 @@ export const RunCommand = cmd({ type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, - }) - }, - handler: async (args) => { - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") + }), + handler: Effect.fn("Cli.run")(function* (args) { + const agentSvc = yield* Agent.Service + yield* Effect.promise(async () => { + let message = [...args.message, ...(args["--"] || [])] + .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + .join(" ") - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir - try { - process.chdir(args.dir) - return process.cwd() - } catch { - UI.error("Failed to change directory to " + args.dir) - process.exit(1) - } - })() - - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] - if (args.file) { - const list = Array.isArray(args.file) ? args.file : [args.file] - - for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) - if (!(await Filesystem.exists(resolvedPath))) { - UI.error(`File not found: ${filePath}`) + const directory = (() => { + if (!args.dir) return undefined + if (args.attach) return args.dir + try { + process.chdir(args.dir) + return process.cwd() + } catch { + UI.error("Failed to change directory to " + args.dir) process.exit(1) } + })() - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + if (args.file) { + const list = Array.isArray(args.file) ? args.file : [args.file] - files.push({ - type: "file", - url: pathToFileURL(resolvedPath).href, - filename: path.basename(resolvedPath), - mime, - }) - } - } - - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) - - if (message.trim().length === 0 && !args.command) { - UI.error("You must provide a message or a command") - process.exit(1) - } - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) - } - - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] - - function title() { - if (args.title === undefined) return - if (args.title !== "") return args.title - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } - - async function session(sdk: KiloClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session - - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id - } - - if (baseID) return baseID - - const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id - } - - async function share(sdk: KiloClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !Flag.KILO_AUTO_SHARE && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) - } - } - - async function execute(sdk: KiloClient) { - function tool(part: ToolPart) { - try { - if (part.tool === "bash") return bash(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } - } - - function emit(type: string, data: Record) { - if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) - return true - } - return false - } - - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { - const toggles = new Map() - - for await (const event of events.stream) { - if ( - event.type === "message.updated" && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true - ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) + for (const filePath of list) { + const resolvedPath = path.resolve(process.cwd(), filePath) + if (!(await Filesystem.exists(resolvedPath))) { + UI.error(`File not found: ${filePath}`) + process.exit(1) } - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue + const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - tool(part) - continue + files.push({ + type: "file", + url: pathToFileURL(resolvedPath).href, + filename: path.basename(resolvedPath), + mime, + }) + } + } + + if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + + if (message.trim().length === 0 && !args.command) { + UI.error("You must provide a message or a command") + process.exit(1) + } + + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exit(1) + } + + const rules: Permission.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } + + async function session(sdk: KiloClient) { + const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + + if (baseID && args.fork) { + const forked = await sdk.session.fork({ sessionID: baseID }) + return forked.data?.id + } + + if (baseID) return baseID + + const name = title() + const result = await sdk.session.create({ title: name, permission: rules }) + return result.data?.id + } + + async function share(sdk: KiloClient, sessionID: string) { + const cfg = await sdk.config.get() + if (!cfg.data) return + if (cfg.data.share !== "auto" && !Flag.KILO_AUTO_SHARE && !args.share) return + const res = await sdk.session.share({ sessionID }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!res.error && "data" in res && res.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + } + } + + async function execute(sdk: KiloClient) { + function tool(part: ToolPart) { + try { + if (part.tool === ShellID.ToolID) return shell(props(part)) + if (part.tool === "glob") return glob(props(part)) + if (part.tool === "grep") return grep(props(part)) + if (part.tool === "read") return read(props(part)) + if (part.tool === "write") return write(props(part)) + if (part.tool === "webfetch") return webfetch(props(part)) + if (part.tool === "edit") return edit(props(part)) + if (part.tool === "websearch") return websearch(props(part)) + if (part.tool === "task") return task(props(part)) + if (part.tool === "todowrite") return todo(props(part)) + if (part.tool === "skill") return skill(props(part)) + return fallback(part) + } catch { + return fallback(part) + } + } + + function emit(type: string, data: Record) { + if (args.format === "json") { + process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + return true + } + return false + } + + const events = await sdk.event.subscribe() + let error: string | undefined + + async function loop() { + const toggles = new Map() + + for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } + + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + if (emit("tool_use", { part })) continue + if (part.state.status === "completed") { + tool(part) + continue + } + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + UI.error(part.state.error) } - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - UI.error(part.state.error) + + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) + } + + if (part.type === "step-start") { + if (emit("step_start", { part })) continue + } + + if (part.type === "step-finish") { + if (emit("step_finish", { part })) continue + } + + if (part.type === "text" && part.time?.end) { + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue + } + UI.empty() + UI.println(text) + UI.empty() + } + + if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) + } + } + + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) + } + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue + UI.error(err) } if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" ) { - if (toggles.get(part.id) === true) continue - task(props(part)) - toggles.set(part.id, true) + break } - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue - } - - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue + if (args["dangerously-skip-permissions"]) { + await sdk.permission.reply({ + requestID: permission.id, + reply: "once", + }) + } else { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", + }) } - UI.empty() - UI.println(text) - UI.empty() - } - - if (part.type === "reasoning" && part.time?.end && args.thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue - } - process.stdout.write(line + EOL) - } - } - - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break - } - - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - }) } } } - } - // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + // Validate agent if specified + const agent = await (async () => { + if (!args.agent) return undefined + const name = args.agent - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + // When attaching, validate against the running server instead of local Instance state. + if (args.attach) { + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name } - const agent = modes.find((a) => a.name === name) - if (!agent) { + const entry = await Effect.runPromise(agentSvc.get(name)) + if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -582,8 +617,7 @@ export const RunCommand = cmd({ ) return undefined } - - if (agent.mode === "subagent") { + if (entry.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -591,76 +625,48 @@ export const RunCommand = cmd({ ) return undefined } - return name + })() + + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) } + await share(sdk, sessionID) - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ - sessionID, - agent, - model, - variant: args.variant, - parts: [...files, { type: "text", text: message }], + loop().catch((e) => { + console.error(e) + process.exit(1) }) + + if (args.command) { + await sdk.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + } else { + const model = args.model ? Provider.parseModel(args.model) : undefined + await sdk.session.prompt({ + sessionID, + agent, + model, + variant: args.variant, + parts: [...files, { type: "text", text: message }], + }) + } } - } - if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.KILO_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.KILO_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() - const sdk = createKiloClient({ baseUrl: args.attach, directory, headers }) - return await execute(sdk) - } + if (args.attach) { + const headers = ServerAuth.headers({ password: args.password, username: args.username }) + const sdk = createKiloClient({ baseUrl: args.attach, directory, headers }) + return await execute(sdk) + } - await bootstrap(process.cwd(), async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) return Server.Default().app.fetch(request) @@ -668,5 +674,5 @@ export const RunCommand = cmd({ const sdk = createKiloClient({ baseUrl: "http://kilo.internal", fetch: fetchFn }) await execute(sdk) }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 7cb1de8dc7..1b502e874e 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,21 +1,24 @@ +import { Effect } from "effect" import { Server } from "../../server/server" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -export const ServeCommand = cmd({ +export const ServeCommand = effectCmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", - handler: async (args) => { + // Server loads instances per-request via x-kilo-directory header — no + // need for an ambient project InstanceContext at startup. + instance: false, + handler: Effect.fn("Cli.serve")(function* (args) { if (!Flag.KILO_SERVER_PASSWORD) { console.log("Warning: KILO_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index f6cfba94c5..f18b1702eb 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -1,8 +1,9 @@ import type { Argv } from "yargs" +import { Effect } from "effect" import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { Session } from "@/session/session" import { SessionID } from "../../session/schema" -import { bootstrap } from "../bootstrap" import { UI } from "../ui" import { Locale } from "@/util/locale" import { Flag } from "@opencode-ai/core/flag/flag" @@ -11,7 +12,6 @@ import { Process } from "@/util/process" import { EOL } from "os" import path from "path" import { which } from "../../util/which" -import { AppRuntime } from "@/effect/app-runtime" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -47,36 +47,30 @@ export const SessionCommand = cmd({ async handler() {}, }) -export const SessionDeleteCommand = cmd({ +export const SessionDeleteCommand = effectCmd({ command: "delete ", describe: "delete a session", - builder: (yargs: Argv) => { - return yargs.positional("sessionID", { + builder: (yargs) => + yargs.positional("sessionID", { describe: "session ID to delete", type: "string", demandOption: true, - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const sessionID = SessionID.make(args.sessionID) - try { - await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) - } catch { - UI.error(`Session not found: ${args.sessionID}`) - process.exit(1) - } - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) - UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) - }) - }, + }), + handler: Effect.fn("Cli.session.delete")(function* (args) { + const svc = yield* Session.Service + const sessionID = SessionID.make(args.sessionID) + // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. + yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) + yield* svc.remove(sessionID) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) + }), }) -export const SessionListCommand = cmd({ +export const SessionListCommand = effectCmd({ command: "list", describe: "list sessions", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .option("max-count", { alias: "n", describe: "limit to N most recent sessions", @@ -87,28 +81,18 @@ export const SessionListCommand = cmd({ type: "string", choices: ["table", "json"], default: "table", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const sessions = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })), - ) + }), + handler: Effect.fn("Cli.session.list")(function* (args) { + const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) - if (sessions.length === 0) { - return - } + if (sessions.length === 0) return - let output: string - if (args.format === "json") { - output = formatSessionJSON(sessions) - } else { - output = formatSessionTable(sessions) - } + const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) - const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" + const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" - if (shouldPaginate) { + if (shouldPaginate) { + yield* Effect.promise(async () => { const proc = Process.spawn(pagerCmd(), { stdin: "pipe", stdout: "inherit", @@ -123,11 +107,11 @@ export const SessionListCommand = cmd({ proc.stdin.write(output) proc.stdin.end() await proc.exited - } else { - console.log(output) - } - }) - }, + }) + } else { + console.log(output) + } + }), }) function formatSessionTable(sessions: Session.Info[]): string { diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 6d00fdf9f4..0124a26932 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,12 +1,10 @@ -import type { Argv } from "yargs" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { Session } from "@/session/session" -import { bootstrap } from "../bootstrap" import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" -import { Instance } from "../../project/instance" -import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" interface SessionStats { totalSessions: number @@ -47,11 +45,11 @@ interface SessionStats { medianTokensPerSession: number } -export const StatsCommand = cmd({ +export const StatsCommand = effectCmd({ command: "stats", describe: "show token usage and cost statistics", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .option("days", { describe: "show stats for the last N days (default: all time)", type: "number", @@ -66,35 +64,32 @@ export const StatsCommand = cmd({ .option("project", { describe: "filter by project (default: all projects, empty string: current project)", type: "string", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const stats = await aggregateSessionStats(args.days, args.project) - - let modelLimit: number | undefined - if (args.models === true) { - modelLimit = Infinity - } else if (typeof args.models === "number") { - modelLimit = args.models - } - - displayStats(stats, args.tools, modelLimit) - }) - }, + }), + handler: Effect.fn("Cli.stats")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project) + let modelLimit: number | undefined + if (args.models === true) { + modelLimit = Infinity + } else if (typeof args.models === "number") { + modelLimit = args.models + } + displayStats(stats, args.tools, modelLimit) + }), }) -async function getCurrentProject(): Promise { - return Instance.project -} +const getAllSessions = Effect.sync(() => + Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)), +) -async function getAllSessions(): Promise { - const rows = Database.use((db) => db.select().from(SessionTable).all()) - return rows.map((row) => Session.fromRow(row)) -} - -export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { - const sessions = await getAllSessions() +const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( + days?: number, + projectFilter?: string, + currentProject?: Project.Info, +) { + const svc = yield* Session.Service + const sessions = yield* getAllSessions const MS_IN_DAY = 24 * 60 * 60 * 1000 const cutoffTime = (() => { @@ -117,7 +112,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin if (projectFilter !== undefined) { if (projectFilter === "") { - const currentProject = await getCurrentProject() + if (!currentProject) throw new Error("currentProject required when projectFilter is empty string") filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id) } else { filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter) @@ -163,122 +158,111 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin const sessionTotalTokens: number[] = [] - const BATCH_SIZE = 20 - for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) { - const batch = filteredSessions.slice(i, i + BATCH_SIZE) + const results = yield* Effect.forEach( + filteredSessions, + (session) => + Effect.gen(function* () { + const messages = yield* svc.messages({ sessionID: session.id }) - const batchPromises = batch.map(async (session) => { - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: session.id })), - ) + let sessionCost = 0 + let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + let sessionToolUsage: Record = {} + let sessionModelUsage: Record< + string, + { + messages: number + tokens: { input: number; output: number; cache: { read: number; write: number } } + cost: number + } + > = {} - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } - let sessionToolUsage: Record = {} - let sessionModelUsage: Record< - string, - { - messages: number - tokens: { - input: number - output: number - cache: { - read: number - write: number + for (const message of messages) { + if (message.info.role === "assistant") { + sessionCost += message.info.cost || 0 + + const modelKey = `${message.info.providerID}/${message.info.modelID}` + if (!sessionModelUsage[modelKey]) { + sessionModelUsage[modelKey] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, + } + } + sessionModelUsage[modelKey].messages++ + sessionModelUsage[modelKey].cost += message.info.cost || 0 + + if (message.info.tokens) { + sessionTokens.input += message.info.tokens.input || 0 + sessionTokens.output += message.info.tokens.output || 0 + sessionTokens.reasoning += message.info.tokens.reasoning || 0 + sessionTokens.cache.read += message.info.tokens.cache?.read || 0 + sessionTokens.cache.write += message.info.tokens.cache?.write || 0 + + sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 + sessionModelUsage[modelKey].tokens.output += + (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) + sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 + sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 } } - cost: number - } - > = {} - for (const message of messages) { - if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - - const modelKey = `${message.info.providerID}/${message.info.modelID}` - if (!sessionModelUsage[modelKey]) { - sessionModelUsage[modelKey] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, + for (const part of message.parts) { + if (part.type === "tool" && part.tool) { + sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 } } - sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 - - if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 - sessionModelUsage[modelKey].tokens.output += - (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) - sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 - sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 - } } - for (const part of message.parts) { - if (part.type === "tool" && part.tool) { - sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 - } + return { + messageCount: messages.length, + sessionCost, + sessionTokens, + sessionTotalTokens: + sessionTokens.input + + sessionTokens.output + + sessionTokens.reasoning + + sessionTokens.cache.read + + sessionTokens.cache.write, + sessionToolUsage, + sessionModelUsage, + earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, + latestTime: session.time.updated, + } + }), + { concurrency: 20 }, + ) + + for (const result of results) { + earliestTime = Math.min(earliestTime, result.earliestTime) + latestTime = Math.max(latestTime, result.latestTime) + sessionTotalTokens.push(result.sessionTotalTokens) + + stats.totalMessages += result.messageCount + stats.totalCost += result.sessionCost + stats.totalTokens.input += result.sessionTokens.input + stats.totalTokens.output += result.sessionTokens.output + stats.totalTokens.reasoning += result.sessionTokens.reasoning + stats.totalTokens.cache.read += result.sessionTokens.cache.read + stats.totalTokens.cache.write += result.sessionTokens.cache.write + + for (const [tool, count] of Object.entries(result.sessionToolUsage)) { + stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count + } + + for (const [model, usage] of Object.entries(result.sessionModelUsage)) { + if (!stats.modelUsage[model]) { + stats.modelUsage[model] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, } } - - return { - messageCount: messages.length, - sessionCost, - sessionTokens, - sessionTotalTokens: - sessionTokens.input + - sessionTokens.output + - sessionTokens.reasoning + - sessionTokens.cache.read + - sessionTokens.cache.write, - sessionToolUsage, - sessionModelUsage, - earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, - latestTime: session.time.updated, - } - }) - - const batchResults = await Promise.all(batchPromises) - - for (const result of batchResults) { - earliestTime = Math.min(earliestTime, result.earliestTime) - latestTime = Math.max(latestTime, result.latestTime) - sessionTotalTokens.push(result.sessionTotalTokens) - - stats.totalMessages += result.messageCount - stats.totalCost += result.sessionCost - stats.totalTokens.input += result.sessionTokens.input - stats.totalTokens.output += result.sessionTokens.output - stats.totalTokens.reasoning += result.sessionTokens.reasoning - stats.totalTokens.cache.read += result.sessionTokens.cache.read - stats.totalTokens.cache.write += result.sessionTokens.cache.write - - for (const [tool, count] of Object.entries(result.sessionToolUsage)) { - stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count - } - - for (const [model, usage] of Object.entries(result.sessionModelUsage)) { - if (!stats.modelUsage[model]) { - stats.modelUsage[model] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, - } - } - stats.modelUsage[model].messages += usage.messages - stats.modelUsage[model].tokens.input += usage.tokens.input - stats.modelUsage[model].tokens.output += usage.tokens.output - stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read - stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write - stats.modelUsage[model].cost += usage.cost - } + stats.modelUsage[model].messages += usage.messages + stats.modelUsage[model].tokens.input += usage.tokens.input + stats.modelUsage[model].tokens.output += usage.tokens.output + stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read + stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write + stats.modelUsage[model].cost += usage.cost } } @@ -307,7 +291,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin : sessionTotalTokens[mid] return stats -} +}) export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) { const width = 56 diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8ec54d1e7e..c39ccf1f88 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -28,6 +28,7 @@ import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" +import { SyncProviderV2 } from "@tui/context/sync-v2" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel } from "@tui/component/dialog-model" import { useConnected } from "@tui/component/use-connected" @@ -168,27 +169,29 @@ export function tui(input: { > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index e1231fef88..4f8f8433bc 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { errorMessage } from "@/util/error" import { validateSession } from "./validate-session" +import { ServerAuth } from "@/server/auth" export const AttachCommand = cmd({ command: "attach ", @@ -38,6 +39,11 @@ export const AttachCommand = cmd({ alias: ["p"], type: "string", describe: "basic auth password (defaults to KILO_SERVER_PASSWORD)", + }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to KILO_SERVER_USERNAME or 'opencode')", }), handler: async (args) => { const unguard = win32InstallCtrlCGuard() @@ -60,12 +66,7 @@ export const AttachCommand = cmd({ return args.dir } })() - const headers = (() => { - const password = args.password ?? process.env.KILO_SERVER_PASSWORD - if (!password) return undefined - const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` - return { Authorization: auth } - })() + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const config = await TuiConfig.get() try { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index aa1dd7a83b..d5ddc08f6e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -750,9 +750,18 @@ export function Prompt(props: PromptProps) { return false } + const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { - const res = await sdk.client.session.create({ workspace: props.workspaceID }) + const res = await sdk.client.session.create({ + workspace: props.workspaceID, + agent: agent.name, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + }) if (res.error) { console.log("Creating a session failed:", res.error) @@ -792,7 +801,6 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode - const variant = local.model.variant.current() const editorSelection = editorContext() const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index d873d0047d..2e2a03ae42 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -68,29 +68,73 @@ function normalize(raw: Record) { } } -async function resolvePlugins(config: Info, configFilepath: string) { - if (!config.plugin) return config - for (let i = 0; i < config.plugin.length; i++) { - config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath) - } - return config -} - -async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { - const data = await loadFile(file) - acc.result = mergeDeep(acc.result, data) - if (!data.plugin?.length) return - - const scope = pluginScope(file, ctx) - const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec) => ({ spec, scope, source: file })), - ]) - acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins -} - const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { + const afs = yield* AppFileSystem.Service + + const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const plugins = config.plugin + if (!plugins) return config + for (let i = 0; i < plugins.length; i++) { + plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath)) + } + return config + }) + + const load = (text: string, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + ) + const data = ConfigParse.jsonc(expanded, configFilepath) + if (!isRecord(data)) return {} as Info + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const validated = ConfigParse.schema(Info, normalize(data), configFilepath) + return yield* resolvePlugins(validated, configFilepath) + }).pipe( + // catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema + // can sync-throw — those become defects, which orElseSucceed wouldn't catch. + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("invalid tui config", { path: configFilepath, cause }) + return {} as Info + }), + ), + ) + + const loadFile = (filepath: string): Effect.Effect => + Effect.gen(function* () { + // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. + // Matches how parse/schema/plugin failures in load() are handled — every + // broken-config path degrades gracefully rather than crashing TUI startup. + const text = yield* afs.readFileStringSafe(filepath).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("failed to read tui config", { path: filepath, cause }) + return undefined + }), + ), + ) + if (!text) return {} as Info + return yield* load(text, filepath) + }) + + const mergeFile = (acc: Acc, file: string) => + Effect.gen(function* () { + const data = yield* loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins + }) + // Every config dir we may read from: global config dir, any `.opencode` // folders between cwd and home, and KILO_CONFIG_DIR. const directories = yield* ConfigPaths.directories(ctx.directory) @@ -104,19 +148,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 1. Global tui config (lowest precedence). for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } // 2. Explicit KILO_TUI_CONFIG override, if set. if (Flag.KILO_TUI_CONFIG) { const configFile = Flag.KILO_TUI_CONFIG - yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, configFile) log.debug("loaded custom tui config", { path: configFile }) } // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } // 4. `.opencode` directories (and KILO_CONFIG_DIR) discovered while @@ -127,7 +171,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: for (const dir of dirs) { if (!dir.endsWith(".opencode") && dir !== Flag.KILO_CONFIG_DIR) continue for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } } @@ -192,29 +236,3 @@ export async function waitForDependencies() { export async function get() { return runPromise((svc) => svc.get()) } - -async function loadFile(filepath: string): Promise { - const text = await ConfigPaths.readFile(filepath) - if (!text) return {} - return load(text, filepath).catch((error) => { - log.warn("failed to load tui config", { path: filepath, error }) - return {} - }) -} - -async function load(text: string, configFilepath: string): Promise { - return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }) - .then((expanded) => ConfigParse.jsonc(expanded, configFilepath)) - .then((data) => { - if (!isRecord(data)) return {} - - // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json - // (mirroring the old opencode.json shape) still get their settings applied. - return ConfigParse.schema(Info, normalize(data), configFilepath) - }) - .then((data) => resolvePlugins(data, configFilepath)) - .catch((error) => { - log.warn("invalid tui config", { path: configFilepath, error }) - return {} - }) -} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx new file mode 100644 index 0000000000..d9592aa910 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -0,0 +1,307 @@ +import { useEvent } from "@tui/context/event" +import type { + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, +} from "@kilocode/sdk/v2" +import { createStore, produce, reconcile } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" + +function activeAssistant(messages: SessionMessage[]) { + const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + if (index < 0) return + const assistant = messages[index] + return assistant?.type === "assistant" ? assistant : undefined +} + +function activeCompaction(messages: SessionMessage[]) { + const index = messages.findLastIndex((message) => message.type === "compaction") + if (index < 0) return + const compaction = messages[index] + return compaction?.type === "compaction" ? compaction : undefined +} + +function activeShell(messages: SessionMessage[], callID: string) { + const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + if (index < 0) return + const shell = messages[index] + return shell?.type === "shell" ? shell : undefined +} + +function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) +} + +function latestText(assistant: SessionMessageAssistant | undefined) { + return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") +} + +function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, + ) +} + +export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({ + name: "SyncV2", + init: () => { + const [store, setStore] = createStore<{ + messages: { + [sessionID: string]: SessionMessage[] + } + }>({ + messages: {}, + }) + + const event = useEvent() + const sdk = useSDK() + + function update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + setStore( + "messages", + produce((draft) => { + fn((draft[sessionID] ??= [])) + }), + ) + } + + event.subscribe((event) => { + switch (event.type) { + case "session.next.prompted": { + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + time: { created: event.properties.timestamp }, + }) + }) + break + } + case "session.next.synthetic": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "synthetic", + sessionID: event.properties.sessionID, + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.started": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "shell", + callID: event.properties.callID, + command: event.properties.command, + output: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.ended": + update(event.properties.sessionID, (draft) => { + const match = activeShell(draft, event.properties.callID) + if (!match) return + match.output = event.properties.output + match.time.completed = event.properties.timestamp + }) + break + case "session.next.step.started": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp + draft.push({ + id: event.id, + type: "assistant", + agent: event.properties.agent, + model: event.properties.model, + content: [], + snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.step.ended": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = event.properties.finish + currentAssistant.cost = event.properties.cost + currentAssistant.tokens = event.properties.tokens + if (event.properties.snapshot) + currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } + }) + break + case "session.next.step.failed": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = "error" + currentAssistant.error = event.properties.error + }) + break + case "session.next.text.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ type: "text", text: "" }) + }) + break + case "session.next.text.delta": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text += event.properties.delta + }) + break + case "session.next.text.ended": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text = event.properties.text + }) + break + case "session.next.tool.input.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "tool", + id: event.properties.callID, + name: event.properties.name, + time: { created: event.properties.timestamp }, + state: { status: "pending", input: "" }, + }) + }) + break + case "session.next.tool.input.delta": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status === "pending") match.state.input += event.properties.delta + }) + break + case "session.next.tool.input.ended": + break + case "session.next.tool.called": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (!match) return + match.time.ran = event.properties.timestamp + match.provider = event.properties.provider + match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } + }) + break + case "session.next.tool.progress": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state.structured = event.properties.structured + match.state.content = [...event.properties.content] + }) + break + case "session.next.tool.success": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "completed", + input: match.state.input, + structured: event.properties.structured, + content: [...event.properties.content], + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.tool.failed": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "error", + error: event.properties.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.reasoning.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "reasoning", + id: event.properties.reasoningID, + text: "", + }) + }) + break + case "session.next.reasoning.delta": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text += event.properties.delta + }) + break + case "session.next.reasoning.ended": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text = event.properties.text + }) + break + case "session.next.retried": + break + case "session.next.compaction.started": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "compaction", + reason: event.properties.reason, + summary: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.compaction.delta": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (match) match.summary += event.properties.text + }) + break + case "session.next.compaction.ended": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (!match) return + match.summary = event.properties.text + match.include = event.properties.include + }) + break + } + }) + + const result = { + data: store, + session: { + message: { + async sync(sessionID: string) { + const response = await sdk.client.v2.session.messages({ sessionID }) + setStore("messages", sessionID, reconcile(response.data?.items ?? [])) + }, + fromSession(sessionID: string) { + const messages = store.messages[sessionID] + if (!messages) return [] + return messages + }, + }, + }, + } + + return result + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx new file mode 100644 index 0000000000..5b36ebbb8b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -0,0 +1,1087 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui" +import { useSyncV2 } from "@tui/context/sync-v2" +import { SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" +import { useTheme } from "@tui/context/theme" +import { useLocal } from "@tui/context/local" +import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import type { SyntaxStyle } from "@opentui/core" +import { Locale } from "@/util/locale" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import path from "path" +import stripAnsi from "strip-ansi" +import type { + SessionMessage, + SessionMessageAgentSwitched, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, + SessionMessageCompaction, + SessionMessageModelSwitched, + SessionMessageShell, + SessionMessageSynthetic, + SessionMessageUser, + ToolFileContent, + ToolTextContent, +} from "@kilocode/sdk/v2" +import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" + +const id = "internal:session-v2-debug" +const route = "session.v2.messages" + +function currentSessionID(api: TuiPluginApi) { + const current = api.route.current + if (current.name !== "session") return + const sessionID = current.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +function View(props: { api: TuiPluginApi; sessionID: string }) { + const sync = useSyncV2() + const dimensions = useTerminalDimensions() + const { theme, syntax, subtleSyntax } = useTheme() + const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) + const renderedMessages = createMemo(() => messages().toReversed()) + const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + + createEffect(() => { + void sync.session.message.sync(props.sessionID) + }) + + useKeyboard((event) => { + if (event.name !== "escape") return + event.preventDefault() + event.stopPropagation() + props.api.route.navigate("session", { sessionID: props.sessionID }) + }) + + return ( + + + + + + + + + + {(message, index) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + + ) +} + +function MissingData(props: { label: string; detail: string }) { + const { theme } = useTheme() + return ( + + + MISSING DATA {props.label} + + {props.detail} + + ) +} + +function UserMessage(props: { message: SessionMessageUser; index: number }) { + const { theme } = useTheme() + const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])]) + return ( + + + + } + > + {props.message.text} + + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + + {Locale.todayTimeOrDateTime(props.message.time.created)} + + + ) +} + +function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) { + const { theme } = useTheme() + return ( + + Synthetic + {props.message.text} + + ) +} + +function ShellMessage(props: { message: SessionMessageShell }) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi(props.message.output.trim())) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + setExpanded((prev) => !prev) : undefined} + > + + $ {props.message.command} + + {limited()} + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + ) +} + +function CompactionMessage(props: { message: SessionMessageCompaction }) { + const { theme } = useTheme() + return ( + + + {props.message.summary} + + + ) +} + +function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) { + const { theme } = useTheme() + const local = useLocal() + return ( + + + + Switched agent to + {Locale.titlecase(props.message.agent)} + + + ) +} + +function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) { + const { theme } = useTheme() + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + return ( + + + + Switched model to + {model()} + + + ) +} + +function UnknownMessage(props: { message: SessionMessage }) { + return +} + +function AssistantMessage(props: { + message: SessionMessageAssistant + last: boolean + syntax: SyntaxStyle + subtleSyntax: SyntaxStyle +}) { + const { theme } = useTheme() + const local = useLocal() + const duration = createMemo(() => { + if (!props.message.time.completed) return 0 + return props.message.time.completed - props.message.time.created + }) + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)) + return ( + <> + + {(part) => ( + + + + + + + + + + + + )} + + + + + + + {props.message.error} + + + + + + + {Locale.titlecase(props.message.agent)} + · {model()} + + · {Locale.duration(duration())} + + + + + + ) +} + +function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) { + const { theme } = useTheme() + return ( + + + + + + ) +} + +function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) { + const { theme } = useTheme() + const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) + return ( + + + + + + ) +} + +function AssistantTool(props: { part: SessionMessageAssistantTool }) { + const input = createMemo(() => toolInputRecord(props.part.state.input)) + const toolprops = { + get input() { + return input() + }, + get metadata() { + return props.part.provider?.metadata ?? {} + }, + get output() { + return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) + }, + part: props.part, + } + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +type ToolProps = { + input: Record + metadata: Record + output?: string + part: SessionMessageAssistantTool +} + +function GenericTool(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => props.output?.trim() ?? "") + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const maxLines = 3 + const overflow = createMemo(() => lines().length > maxLines) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, maxLines), "…"].join("\n") + }) + return ( + + {props.part.name} {input(props.input)} + + } + > + setExpanded((prev) => !prev) : undefined} + > + + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + ) +} + +function InlineTool(props: { + icon: string + complete: unknown + pending: string + spinner?: boolean + children: JSX.Element + part: SessionMessageAssistantTool +}) { + const { theme } = useTheme() + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const denied = createMemo(() => { + const message = error() + if (!message) return false + return ( + message.includes("QuestionRejectedError") || + message.includes("rejected permission") || + message.includes("user dismissed") + ) + }) + return ( + + + + {props.children} + + + + ~ {props.pending}} when={props.complete}> + {props.icon} {props.children} + + + + + + {error()} + + + ) +} + +function BlockTool(props: { + title: string + children: JSX.Element + part?: SessionMessageAssistantTool + onClick?: () => void + spinner?: boolean +}) { + const { theme } = useTheme() + const renderer = useRenderer() + const [hover, setHover] = createSignal(false) + const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) + return ( + props.onClick && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick?.() + }} + flexShrink={0} + > + + {props.title} + + } + > + {props.title.replace(/^# /, "")} + + {props.children} + + {error()} + + + ) +} + +function Bash(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim())) + const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part)) + const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + + + setExpanded((prev) => !prev) : undefined} + > + + $ {command()} + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + + + {command()} + + + + ) +} + +function Glob(props: ToolProps) { + return ( + + Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(count) => ( + <> + ({count()} {count() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function Read(props: ToolProps) { + const { theme } = useTheme() + const loaded = createMemo(() => + arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"), + ) + return ( + <> + + Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "} + {input(props.input, ["filePath"])} + + + {(filepath) => ( + + + ↳ Loaded {normalizePath(filepath)} + + + )} + + + ) +} + +function Grep(props: ToolProps) { + return ( + + Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(matches) => ( + <> + ({matches()} {matches() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function WebFetch(props: ToolProps) { + return ( + + WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)} + + ) +} + +function CodeSearch(props: ToolProps) { + return ( + + Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function WebSearch(props: ToolProps) { + return ( + + Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function Write(props: ToolProps) { + const { theme, syntax } = useTheme() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const content = createMemo(() => stringValue(props.input.content) ?? "") + return ( + + + + + + + + + + + + Write {normalizePath(filePath())} + + + + ) +} + +function Edit(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const diff = createMemo(() => stringValue(props.metadata.diff)) + return ( + + + {(diff) => ( + + + 120 ? "split" : "unified"} + filetype={filetype(filePath())} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + + + )} + + + + Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })} + + + + ) +} + +function ApplyPatch(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : []))) + const fileTitle = (file: Record) => { + const type = stringValue(file.type) + const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch" + if (type === "delete") return "# Deleted " + relativePath + if (type === "add") return "# Created " + relativePath + if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath + return "← Patched " + relativePath + } + return ( + + 0}> + + {(file) => ( + + + -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"} + + } + > + {(patch) => ( + + 120 ? "split" : "unified"} + filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + )} + + + )} + + + + + Patch + + + + ) +} + +function TodoWrite(props: ToolProps) { + const { theme } = useTheme() + const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : []))) + return ( + + 0 && props.part.state.status === "completed"}> + + + + {(todo) => ( + + {todoIcon(stringValue(todo.status))} {stringValue(todo.content)} + + )} + + + + + + + Updating todos... + + + + ) +} + +function Question(props: ToolProps) { + const { theme } = useTheme() + const questions = createMemo(() => + arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])), + ) + const answers = createMemo(() => arrayValue(props.metadata.answers)) + return ( + + 0}> + + + + {(question, index) => ( + + {stringValue(question.question)} + {formatAnswer(answers()[index()])} + + )} + + + + + + + Asked {questions().length} question{questions().length === 1 ? "" : "s"} + + + + ) +} + +function Skill(props: ToolProps) { + return ( + + Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}" + + ) +} + +function Task(props: ToolProps) { + const content = createMemo(() => { + const description = stringValue(props.input.description) + if (!description) return pendingInput(props.part) + return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}` + }) + return ( + + {content()} + + ) +} + +function Diagnostics(props: { diagnostics: unknown; filePath: string }) { + const { theme } = useTheme() + const errors = createMemo(() => { + if (!isRecord(props.diagnostics)) return [] + const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath] + return arrayValue(value) + .flatMap((item) => (isRecord(item) ? [item] : [])) + .filter((diagnostic) => diagnostic.severity === 1) + .slice(0, 3) + }) + return ( + + + + {(diagnostic) => Error {stringValue(diagnostic.message)}} + + + + ) +} + +function toolOutput(content?: Array) { + return (content ?? []) + .map((item) => { + if (item.type === "text") return item.text.trim() + return `[file ${item.name ?? item.uri}]` + }) + .filter(Boolean) + .join("\n") +} + +function toolInputRecord(input: string | Record) { + if (typeof input === "string") return {} + return input +} + +function pendingInput(part: SessionMessageAssistantTool) { + if (part.state.status !== "pending") return "" + return part.state.input.trim() +} + +function toolComplete(part: SessionMessageAssistantTool) { + if (part.state.status === "pending") return pendingInput(part) + return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +function numberValue(value: unknown) { + return typeof value === "number" ? value : undefined +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function input(input: Record, omit?: string[]) { + const primitives = Object.entries(input).filter(([key, value]) => { + if (omit?.includes(key)) return false + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + }) + if (primitives.length === 0) return "" + return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]` +} + +function normalizePath(input?: string) { + if (!input) return "" + const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input) + const relative = path.relative(process.cwd(), absolute) + if (!relative) return "." + if (!relative.startsWith("..")) return relative + return absolute +} + +function filetype(input?: string) { + if (!input) return "none" + const language = LANGUAGE_EXTENSIONS[path.extname(input)] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} + +function todoIcon(status?: string) { + if (status === "completed") return "✓" + if (status === "in_progress") return "~" + if (status === "cancelled") return "✕" + return "☐" +} + +function formatAnswer(answer: unknown) { + if (!Array.isArray(answer)) return "(no answer)" + if (answer.length === 0) return "(no answer)" + return answer.filter((item): item is string => typeof item === "string").join(", ") +} + +const tui: TuiPlugin = async (api) => { + api.route.register([ + { + name: route, + render(input) { + const sessionID = input.params?.sessionID + if (typeof sessionID !== "string") { + return Missing sessionID + } + return + }, + }, + ]) + + api.command.register(() => [ + { + title: "View v2 session messages", + value: route, + category: "Debug", + suggested: api.route.current.name === "session", + enabled: api.route.current.name === "session", + onSelect() { + const sessionID = currentSessionID(api) + if (!sessionID) return + api.route.navigate(route, { sessionID }) + api.ui.dialog.clear() + }, + }, + ]) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 401c6bde0a..b4f2c40e45 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -7,7 +7,9 @@ import SidebarTodo from "../feature-plugins/sidebar/todo" import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" import PluginManager from "../feature-plugins/system/plugins" +import SessionV2Debug from "../feature-plugins/system/session-v2" import type { TuiPlugin, TuiPluginModule } from "@kilocode/plugin/tui" +import { Flag } from "@opencode-ai/core/flag/flag" export type InternalTuiPlugin = TuiPluginModule & { id: string @@ -24,4 +26,5 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ SidebarFiles, SidebarFooter, PluginManager, + ...(Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), ] diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index dffe0ed054..3f39d36b26 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import * as Log from "@opencode-ai/core/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { readPackageThemes, readPluginId, @@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { state.pending.delete(spec) return true } - const ready = await Instance.provide({ + const ready = await WithInstance.provide({ directory: state.directory, fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), }).catch((error) => { @@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) { } runtime = next try { - await Instance.provide({ + await WithInstance.provide({ directory: cwd, fn: async () => { const records = Flag.KILO_PURE ? [] : (config.plugin_origins ?? []) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 69d00b9ef8..e0860c46f8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -37,7 +37,8 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/bash" +import { ShellTool } from "@/tool/shell" +import { ShellID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1552,8 +1553,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - - + + @@ -1784,7 +1785,7 @@ function BlockTool(props: { ) } -function Bash(props: ToolProps) { +function Shell(props: ToolProps) { const { theme } = useTheme() const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index ab27aaea65..2d7c5d9404 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -15,6 +15,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" +import { ShellID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -287,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + if (permission === ShellID.ToolID) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 20d666c56d..90ff2b4d4f 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -1,17 +1,19 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" +import { WithInstance } from "@/project/with-instance" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" -import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" -import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime" +import { AppRuntime } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" +import { Effect } from "effect" +import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" ensureProcessMetadata("worker") @@ -48,7 +50,7 @@ let server: Awaited> | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } - const auth = getAuthorizationHeader() + const auth = ServerAuth.header() if (auth && !headers["authorization"] && !headers["Authorization"]) { headers["Authorization"] = auth } @@ -75,30 +77,28 @@ export const rpc = { return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { - await Instance.provide({ + await WithInstance.provide({ directory: input.directory, - init: await getBootstrapRunEffect(), fn: async () => { await upgrade().catch(() => {}) }, }) }, async reload() { - await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true))) + await AppRuntime.runPromise( + Effect.gen(function* () { + const cfg = yield* Config.Service + yield* cfg.invalidate() + yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) + }), + ) }, async shutdown() { Log.Default.info("worker shutting down") - await InstanceStore.disposeAllInstances() + await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) }, } Rpc.listen(rpc) - -function getAuthorizationHeader(): string | undefined { - const password = Flag.KILO_SERVER_PASSWORD - if (!password) return undefined - const username = Flag.KILO_SERVER_USERNAME ?? "opencode" - return `Basic ${btoa(`${username}:${password}`)}` -} diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index b09e894256..dfe710fcdd 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,6 +1,7 @@ +import { Effect } from "effect" import { Server } from "../../server/server" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" import open from "open" @@ -28,16 +29,19 @@ function getNetworkIPs() { return results } -export const WebCommand = cmd({ +export const WebCommand = effectCmd({ command: "web", builder: (yargs) => withNetworkOptions(yargs), describe: "start opencode server and open web interface", - handler: async (args) => { + // Server loads instances per-request via x-kilo-directory header — no + // ambient project InstanceContext needed at startup. + instance: false, + handler: Effect.fn("Cli.web")(function* (args) { if (!Flag.KILO_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! KILO_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) UI.empty() UI.println(UI.logo(" ")) UI.empty() @@ -75,7 +79,6 @@ export const WebCommand = cmd({ open(displayUrl).catch(() => {}) } - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index cc4dd2ed7e..ada5f8677d 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -2,7 +2,9 @@ import type { Argv } from "yargs" import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" -import { cmd } from "./cmd/cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { Instance } from "@/project/instance" +import { cmd, type WithDoubleDash } from "./cmd/cmd" /** * User-visible command failure. Throw via `fail("...")` from an effectCmd handler @@ -17,10 +19,47 @@ export class CliError extends Schema.TaggedErrorClass()("CliError", { export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) +interface EffectCmdOpts { + command: string | readonly string[] + aliases?: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** + * Whether the command needs a project InstanceContext. Defaults to true. + * + * `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})` + * so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via + * `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy + * `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin + * init + LSP/File/etc forks) eagerly. + * + * `false`: skip the instance entirely. Saves the InstanceBootstrap work and + * suppresses the `server.instance.disposed` IPC event. The handler runs + * directly under AppRuntime — it can yield any `AppServices` but must not + * yield `InstanceRef` (it'd be undefined, causing a defect). + * + * Function form: `(args) => boolean` decides per-invocation. Useful for + * commands like `run --attach ` where one flag flips between local + * (needs instance) and remote (doesn't). + * + * Use `false` for commands that don't read project state (e.g. `models`, + * `serve`, `web`, `account`, `db`, `upgrade`). + */ + instance?: boolean | ((args: Args) => boolean) + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: WithDoubleDash) => Effect.Effect +} + /** * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. * + * The handler is wrapped in `Effect.ensuring(store.dispose(ctx))` so the loaded + * InstanceContext is disposed (runDisposers + IPC `server.instance.disposed`) + * on every Exit — success, typed failure, defect, or interruption. Matches the + * legacy `bootstrap()` finally-disposal semantics without per-handler boilerplate. + * * Errors propagate to the existing top-level handler in `src/index.ts`; use * `fail("...")` for user-visible domain failures (clean exit, formatted message). * @@ -29,22 +68,36 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError( * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's * `Command.make(...)` won't touch any handler bodies. */ -export const effectCmd = (opts: { - command: string | readonly string[] - describe: string | false - builder?: (yargs: Argv) => Argv - /** Defaults to process.cwd(). Override for commands that take a directory positional. */ - directory?: (args: Args) => string - handler: (args: Args) => Effect.Effect -}) => +export const effectCmd = (opts: EffectCmdOpts) => cmd<{}, Args>({ command: opts.command, + aliases: opts.aliases, describe: opts.describe, builder: opts.builder as never, async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. - const args = rawArgs as unknown as Args + const args = rawArgs as unknown as WithDoubleDash + const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false + if (!useInstance) { + await AppRuntime.runPromise(opts.handler(args)) + return + } const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args)))) + // Two-phase: load ctx, then run body inside Instance.current ALS. + // Effect's InstanceRef is provided via fiber context, but that context is + // lost across `await` inside `Effect.promise(async () => ...)` callbacks + // — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())` + // there, attach() falls back to Instance.current ALS, which Node preserves + // across awaits. Matches the pre-effectCmd `bootstrap()` behavior. + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), + ) + try { + await Instance.restore(ctx, () => + AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), + ) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } }, }) diff --git a/packages/opencode/src/cli/effect/prompt.ts b/packages/opencode/src/cli/effect/prompt.ts index 7f9cd8cfe6..2713f1a5b8 100644 --- a/packages/opencode/src/cli/effect/prompt.ts +++ b/packages/opencode/src/cli/effect/prompt.ts @@ -6,15 +6,27 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg)) export const log = { info: (msg: string) => Effect.sync(() => prompts.log.info(msg)), + error: (msg: string) => Effect.sync(() => prompts.log.error(msg)), + warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)), + success: (msg: string) => Effect.sync(() => prompts.log.success(msg)), +} + +const optional = (result: Value | symbol) => { + if (prompts.isCancel(result)) return Option.none() + return Option.some(result) } export const select = (opts: Parameters>[0]) => - Effect.tryPromise(() => prompts.select(opts)).pipe( - Effect.map((result) => { - if (prompts.isCancel(result)) return Option.none() - return Option.some(result) - }), - ) + Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result))) + +export const autocomplete = (opts: Parameters>[0]) => + Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result))) + +export const text = (opts: Parameters[0]) => + Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result))) + +export const password = (opts: Parameters[0]) => + Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result))) export const spinner = () => { const s = prompts.spinner() diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4236d4d3ef..10fe396673 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,11 +12,8 @@ import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { type InstanceContext } from "../project/instance" -import { InstanceStore } from "../project/instance-store" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" -import { GlobalBus } from "@/bus/global" -import { Event } from "../server/event" import { Account } from "@/account/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" @@ -195,8 +192,14 @@ export const Info = Schema.Struct({ ]), ), ).annotate({ description: "MCP (Model Context Protocol) server configurations" }), - formatter: Schema.optional(ConfigFormatter.Info), - lsp: Schema.optional(ConfigLSP.Info), + formatter: Schema.optional(ConfigFormatter.Info).annotate({ + description: + "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", + }), + lsp: Schema.optional(ConfigLSP.Info).annotate({ + description: + "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", + }), instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional instruction files or patterns to include", }), @@ -289,9 +292,9 @@ export interface Interface { readonly get: () => Effect.Effect readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect - readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect - readonly updateGlobal: (config: Info) => Effect.Effect - readonly invalidate: (wait?: boolean) => Effect.Effect + readonly update: (config: Info) => Effect.Effect + readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }> + readonly invalidate: () => Effect.Effect readonly directories: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } @@ -352,15 +355,7 @@ export const layer = Layer.effect( const env = yield* Env.Service const npmSvc = yield* Npm.Service - const readConfigFile = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe( - Effect.catchIf( - (e) => e.reason._tag === "NotFound", - () => Effect.succeed(undefined), - ), - Effect.orDie, - ) - }) + const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) const loadConfig = Effect.fnUntraced(function* ( text: string, @@ -730,37 +725,17 @@ export const layer = Layer.effect( ) }) - const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) { + const update = Effect.fn("Config.update")(function* (config: Info) { const dir = yield* InstanceState.directory const file = path.join(dir, "config.json") const existing = yield* loadFile(file) yield* fs .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) - if (options?.dispose !== false) { - // Fail loudly if no instance is bound — silently skipping would - // mask "config update without an active instance" bugs. The throw - // comes from `Instance.current` inside `InstanceState.context`. - const ctx = yield* InstanceState.context - yield* Effect.promise(() => InstanceStore.disposeInstance(ctx)) - } }) - const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { + const invalidate = Effect.fn("Config.invalidate")(function* () { yield* invalidateGlobal - const task = InstanceStore.disposeAllInstances() - .catch(() => undefined) - .finally(() => - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }), - ) - if (wait) yield* Effect.promise(() => task) - else void task }) const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { @@ -784,9 +759,8 @@ export const layer = Layer.effect( if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } - // Only tear down running instances if the config actually changed. if (changed) yield* invalidate() - return next + return { info: next, changed } }) return Service.of({ diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index fa9fe6f41f..f415c49809 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -1,11 +1,9 @@ export * as ConfigPaths from "./paths" import path from "path" -import { Filesystem } from "@/util/filesystem" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { unique } from "remeda" -import { JsonError } from "./error" import * as Effect from "effect/Effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -45,11 +43,3 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc export function fileInDirectory(dir: string, name: string) { return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] } - -/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ -export async function readFile(filepath: string) { - return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") return - throw new JsonError({ path: filepath }, { cause: err }) - }) -} diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 66f3a9b378..76ed26d302 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime } from "effect" +import { Layer, ManagedRuntime } from "effect" import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" @@ -40,13 +40,13 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceStore } from "@/project/instance-store" +import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" @@ -94,18 +94,17 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, -).pipe(Layer.provideMerge(Observability.layer)) +).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick @@ -132,15 +131,3 @@ export const AppRuntime: Runtime = { }, dispose: () => rt.dispose(), } - -let bootstrapRun: Promise> -export function getBootstrapRunEffect(): Promise> { - if (!bootstrapRun) { - bootstrapRun = AppRuntime.runPromise( - Effect.gen(function* () { - return (yield* InstanceBootstrap.Service).run - }), - ) - } - return bootstrapRun -} diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index a37ad6a474..08e68cf4b4 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -123,7 +123,9 @@ export const layer = Layer.effect( const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]) + yield* Effect.forkScoped( + subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]), + ) } if (ctx.project.vcs === "git") { @@ -135,7 +137,7 @@ export const layer = Layer.effect( const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", ) - yield* subscribe(vcsDir, ignore) + yield* Effect.forkScoped(subscribe(vcsDir, ignore)) } } }, diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 16a8624474..fff1d70b2a 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -24,6 +24,7 @@ const fail = (err: unknown) => text: () => "", stdout: Buffer.alloc(0), stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + truncated: false, }) satisfies Result export type Kind = "added" | "deleted" | "modified" @@ -45,16 +46,28 @@ export type Stat = { readonly deletions: number } +export type Patch = { + readonly text: string + readonly truncated: boolean +} + +export interface PatchOptions { + readonly context?: number + readonly maxOutputBytes?: number +} + export interface Result { readonly exitCode: number readonly text: () => string readonly stdout: Buffer readonly stderr: Buffer + readonly truncated: boolean } export interface Options { readonly cwd: string readonly env?: Record + readonly maxOutputBytes?: number } export interface Interface { @@ -68,6 +81,10 @@ export interface Interface { readonly status: (cwd: string) => Effect.Effect readonly diff: (cwd: string, ref: string) => Effect.Effect readonly stats: (cwd: string, ref: string) => Effect.Effect + readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect + readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect + readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect + readonly statUntracked: (cwd: string, file: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -96,15 +113,31 @@ export const layer = Layer.effect( stderr: "pipe", }) const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) + const collect = (stream: typeof handle.stdout) => + Stream.runFold( + stream, + () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }), + (acc, chunk) => { + if (opts.maxOutputBytes === undefined) { + acc.chunks.push(chunk) + acc.bytes += chunk.length + return acc + } + + const remaining = opts.maxOutputBytes - acc.bytes + if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining)) + acc.bytes += chunk.length + acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes + return acc + }, + ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) + const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 }) return { exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), + text: () => stdout.buffer.toString("utf8"), + stdout: stdout.buffer, + stderr: stderr.buffer, + truncated: stdout.truncated || stderr.truncated, } satisfies Result }, Effect.scoped, @@ -240,6 +273,61 @@ export const layer = Layer.effect( }) }) + const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchUntracked = Effect.fn("Git.patchUntracked")(function* ( + cwd: string, + file: string, + options?: PatchOptions, + ) { + const result = yield* run( + [ + "diff", + "--no-index", + "--patch", + "--no-ext-diff", + "--no-renames", + `--unified=${options?.context ?? 3}`, + "--", + "/dev/null", + file, + ], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) { + const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], { + cwd, + maxOutputBytes: 4096, + }) + if (result.truncated) return + const parts = result.text().split("\t") + if (parts.length < 2) return + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) + return { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat + }) + return Service.of({ run, branch, @@ -251,6 +339,10 @@ export const layer = Layer.effect( status, diff, stats, + patch, + patchAll, + patchUntracked, + statUntracked, }) }), ) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 3fedd41d2c..d93670709e 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -144,7 +144,6 @@ interface State { } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) return evalRule(permission, pattern, ...rulesets) } diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 4f7e23362f..d5162cfbbe 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -14,7 +14,14 @@ const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 -const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]) +const ALLOWED_MODELS = new Set([ + "gpt-5.5", + "gpt-5.2", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-mini", +]) interface PkceCodes { verifier: string diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1f1b571bdc..0aeadc41ae 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -10,6 +10,7 @@ import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createKiloClient } from "@kilocode/sdk" import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" @@ -124,11 +125,7 @@ export const layer = Layer.effect( const client = createKiloClient({ baseUrl: "http://localhost:4096", directory: ctx.directory, - headers: Flag.KILO_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.KILO_SERVER_USERNAME ?? "opencode"}:${Flag.KILO_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, + headers: ServerAuth.headers(), fetch: async (...args) => Server.Default().app.fetch(...args), }) const cfg = yield* config.get() diff --git a/packages/opencode/src/project/bootstrap-service.ts b/packages/opencode/src/project/bootstrap-service.ts new file mode 100644 index 0000000000..b20cc54cd6 --- /dev/null +++ b/packages/opencode/src/project/bootstrap-service.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export * as InstanceBootstrap from "./bootstrap-service" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 9f77de2d4d..fb3e1bb32d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -6,32 +6,29 @@ import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" import { Bus } from "../bus" -import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" -import { Context, Effect, Layer } from "effect" +import { Effect, Layer } from "effect" import { Config } from "@/config/config" +import { Service } from "./bootstrap-service" -export interface Interface { - readonly run: Effect.Effect -} - -export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} +export { Service } from "./bootstrap-service" +export type { Interface } from "./bootstrap-service" export const layer = Layer.effect( Service, Effect.gen(function* () { // Yield each bootstrap dep at layer init so `run` itself has R = never. - // This breaks the circular declaration loop through Config → Instance → InstanceStore - // (instance-store.ts only yields this Service tag, never the impl-side services). - const bus = yield* Bus.Service + // InstanceStore imports only the lightweight tag from bootstrap-service.ts, + // so it can depend on bootstrap without importing this implementation graph. const config = yield* Config.Service const file = yield* File.Service const fileWatcher = yield* FileWatcher.Service const format = yield* Format.Service const lsp = yield* LSP.Service const plugin = yield* Plugin.Service + const project = yield* Project.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -43,16 +40,13 @@ export const layer = Layer.effect( yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() - yield* Effect.all( - [lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())), + // Each service self-manages its own slow work via Effect.forkScoped against + // its per-instance state scope. We just await materialization here. + yield* Effect.forEach( + [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), + { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) - - const projectID = ctx.project.id - yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(projectID) - } - }) }).pipe(Effect.withSpan("InstanceBootstrap")) return Service.of({ run }) diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts new file mode 100644 index 0000000000..a7e2bfcb7b --- /dev/null +++ b/packages/opencode/src/project/instance-layer.ts @@ -0,0 +1,11 @@ +import { Effect, Layer } from "effect" +import { InstanceStore } from "./instance-store" + +export const layer = Layer.unwrap( + Effect.promise(async () => { + const { InstanceBootstrap } = await import("./bootstrap") + return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) + }), +) + +export * as InstanceLayer from "./instance-layer" diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts new file mode 100644 index 0000000000..c8803847a0 --- /dev/null +++ b/packages/opencode/src/project/instance-runtime.ts @@ -0,0 +1,16 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { type InstanceContext } from "./instance-context" +import { InstanceStore, type LoadInput } from "./instance-store" + +// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service. +// Delete this module once those callers are migrated to Effect boundaries that +// provide InstanceStore directly. + +export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input))) +export const disposeInstance = (ctx: InstanceContext) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx))) +export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())) +export const reloadInstance = (input: LoadInput) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input))) + +export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 00075be64b..4fa1c3dfff 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -2,32 +2,24 @@ import { GlobalBus } from "@/bus/global" import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceRef } from "@/effect/instance-ref" import { disposeInstance as runDisposers } from "@/effect/instance-registry" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { type InstanceContext } from "./instance-context" +import { InstanceBootstrap } from "./bootstrap-service" import * as Project from "./project" -export interface LoadInput { +export interface LoadInput { directory: string - /** - * Additional setup to run after the default InstanceBootstrap. - * Mainly used by tests for env-var setup or file writes that need the instance ALS context. - */ - init?: Effect.Effect worktree?: string project?: Project.Info } export interface Interface { - readonly load: (input: LoadInput) => Effect.Effect - readonly reload: (input: LoadInput) => Effect.Effect + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect - readonly provide: ( - input: LoadInput, - effect: Effect.Effect, - ) => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -36,14 +28,15 @@ interface Entry { readonly deferred: Deferred.Deferred } -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service + const bootstrap = yield* InstanceBootstrap.Service const scope = yield* Scope.Scope const cache = new Map() - const boot = (input: LoadInput & { directory: string }) => + const boot = (input: LoadInput & { directory: string }) => Effect.gen(function* () { const ctx: InstanceContext = input.project && input.worktree @@ -59,7 +52,7 @@ export const layer: Layer.Layer = Layer.effect( project: result.project, })), ) - if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx)) + yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx)) return ctx }).pipe(Effect.withSpan("InstanceStore.boot")) @@ -70,7 +63,7 @@ export const layer: Layer.Layer = Layer.effect( return true }) - const completeLoad = (directory: string, input: LoadInput, entry: Entry) => + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => Effect.gen(function* () { const exit = yield* Effect.exit(boot({ ...input, directory })) if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) @@ -106,7 +99,7 @@ export const layer: Layer.Layer = Layer.effect( return true }) - const load = (input: LoadInput): Effect.Effect => { + const load = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -124,7 +117,7 @@ export const layer: Layer.Layer = Layer.effect( ).pipe(Effect.withSpan("InstanceStore.load")) } - const reload = (input: LoadInput): Effect.Effect => { + const reload = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -178,7 +171,7 @@ export const layer: Layer.Layer = Layer.effect( return yield* cachedDisposeAll }) - const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) @@ -195,13 +188,4 @@ export const layer: Layer.Layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) -export const runtime = makeRuntime(Service, defaultLayer) - -// Promise-returning helpers for callers without an Effect runtime in scope. -// They route through `runtime` (not a yielded Service from a fresh runtime) -// so they share the cache that `Instance.provide` populates. -export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) -export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) -export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) - export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 5b2bcf6b32..a54291cf0c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,17 +1,8 @@ -import { Effect } from "effect" import { context, type InstanceContext } from "./instance-context" -import { InstanceStore } from "./instance-store" export type { InstanceContext } from "./instance-context" -export type { LoadInput } from "./instance-store" export const Instance = { - async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - const ctx = await InstanceStore.runtime.runPromise((store) => - store.load({ directory: input.directory, init: input.init }), - ) - return context.provide(ctx, async () => input.fn()) - }, get current() { return context.use() }, diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 0cd476fc6f..38f6543c90 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -10,6 +10,9 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Bus } from "@/bus" +import { Command } from "@/command" +import { InstanceState } from "@/effect/instance-state" import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" @@ -108,6 +111,12 @@ export type UpdatePayload = Types.DeepMutable Effect.Effect readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> readonly discover: (input: Info) => Effect.Effect readonly list: () => Effect.Effect @@ -127,13 +136,14 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service > = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const bus = yield* Bus.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -417,6 +427,21 @@ export const layer: Layer.Layer< ) }) + const initState = yield* InstanceState.make( + Effect.fn("Project.initState")(function* (ctx) { + yield* bus.subscribe(Command.Event.Executed).pipe( + Stream.runForEach((payload) => + payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void, + ), + Effect.forkScoped, + ) + }), + ) + + const init = Effect.fn("Project.init")(function* () { + yield* InstanceState.get(initState) + }) + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] @@ -466,6 +491,7 @@ export const layer: Layer.Layer< }) return Service.of({ + init, fromDirectory, discover, list, @@ -481,6 +507,7 @@ export const layer: Layer.Layer< ) export const defaultLayer = layer.pipe( + Layer.provide(Bus.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 24112cf442..28ac143eec 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,10 +1,8 @@ import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" -import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" @@ -12,20 +10,11 @@ import { zod } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) +const PATCH_CONTEXT_LINES = 2_147_483_647 +const MAX_PATCH_BYTES = 10_000_000 +const MAX_TOTAL_PATCH_BYTES = 10_000_000 -const count = (text: string) => { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length -} - -const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") -}) +const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) const nums = (list: Git.Stat[]) => new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) @@ -38,59 +27,168 @@ const merge = (...lists: Git.Item[][]) => { return [...out.values()] } +const emptyBatch = () => ({ patches: new Map(), capped: false }) + +const parseQuotedPath = (value: string) => { + let out = "" + for (let idx = 1; idx < value.length; idx++) { + const char = value[idx] + if (char === '"') return { value: out, end: idx + 1 } + if (char !== "\\") { + out += char + continue + } + + const next = value[++idx] + if (next === "t") out += "\t" + else if (next === "n") out += "\n" + else if (next === "r") out += "\r" + else if (next === '"' || next === "\\") out += next + else out += next ?? "" + } +} + +const parsePathToken = (value: string) => { + if (!value.startsWith('"')) return value.split("\t")[0] + return parseQuotedPath(value)?.value ?? value +} + +const fileFromDiffPath = (value: string | undefined) => { + if (!value || value === "/dev/null") return + const file = parsePathToken(value) + if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2) + return file +} + +const fileFromGitHeader = (header: string) => { + if (header.startsWith('"')) { + const first = parseQuotedPath(header) + const second = first ? header.slice(first.end).trimStart() : undefined + if (!second) return + if (!second.startsWith('"')) return fileFromDiffPath(second) + return fileFromDiffPath(parseQuotedPath(second)?.value) + } + + const separator = header.indexOf(" b/") + if (separator === -1) return + return fileFromDiffPath(header.slice(separator + 1)) +} + +const fileFromPatchChunk = (chunk: string) => { + const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1] + const before = /^--- (.+)$/m.exec(chunk)?.[1] + const file = fileFromDiffPath(next) ?? fileFromDiffPath(before) + if (file) return file + + const header = /^diff --git (.+)$/m.exec(chunk)?.[1] + return fileFromGitHeader(header ?? "") +} + +const splitGitPatch = (patch: Git.Patch) => { + const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index) + const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) + if (!patch.truncated) return chunks + return chunks.slice(0, -1) +} + +const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) { + if (list.length === 0) return { patches: new Map(), capped: false } + + const result = yield* git.patchAll(cwd, ref, { + context: PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }) + if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES }) + + return { + patches: splitGitPatch(result).reduce((acc, patch, index) => { + const file = fileFromPatchChunk(patch) ?? list[index]?.file + if (!file) return acc + acc.set(file, (acc.get(file) ?? "") + patch) + return acc + }, new Map()), + capped: result.truncated, + } +}) + +const nativePatch = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + item: Git.Item, +) { + const result = + item.code === "??" || !ref + ? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + : yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + if (!result.truncated && result.text) return result.text + + if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES }) + return emptyPatch(item.file) +}) + +const totalPatch = (file: string, patch: string, total: number) => { + if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false } + log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES }) + return { patch: emptyPatch(file), capped: true } +} + +const patchForItem = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + item: Git.Item, + batch: { patches: Map; capped: boolean }, + capped: boolean, +) { + if (capped) return emptyPatch(item.file) + + const batched = batch.patches.get(item.file) + if (batched !== undefined) return batched + if (item.code !== "??" && batch.capped) return emptyPatch(item.file) + return yield* nativePatch(git, cwd, ref, item) +}) + const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, git: Git.Interface, cwd: string, ref: string | undefined, list: Git.Item[], map: Map, + batch: { patches: Map; capped: boolean }, ) { - const base = ref ? yield* git.prefix(cwd) : "" - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = map.get(item.file) - return { - file: item.file, - patch: patch(item.file, before, after), - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) + const next: FileDiff[] = [] + let total = 0 + let capped = false + + for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) { + const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined) + const patch = yield* patchForItem(git, cwd, ref, item, batch, capped) + const result: { patch: string; capped: boolean } = capped + ? { patch, capped: true } + : totalPatch(item.file, patch, total) + capped = capped || result.capped + if (!capped) { + total += Buffer.byteLength(result.patch) + capped = total >= MAX_TOTAL_PATCH_BYTES + } + next.push({ + file: item.file, + patch: result.patch, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + }) + } + + return next }) -const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string | undefined, -) { - if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, nums(stats)) -}) - -const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string, -) { +const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) { const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { concurrency: 3, }) return yield* files( - fs, git, cwd, ref, @@ -99,9 +197,15 @@ const compare = Effect.fnUntraced(function* ( extra.filter((item) => item.code === "??"), ), nums(stats), + yield* batchPatches(git, cwd, ref, list), ) }) +const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) { + if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch()) + return yield* diffAgainstRef(git, cwd, ref) +}) + export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type Mode = Schema.Schema.Type @@ -147,10 +251,9 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { - const fs = yield* AppFileSystem.Service const git = yield* Git.Service const bus = yield* Bus.Service const scope = yield* Scope.Scope @@ -204,23 +307,19 @@ export const layer: Layer.Layer(input: { directory: string; fn: () => R }): Promise { + const ctx = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory: input.directory })), + ) + return context.provide(ctx, () => input.fn()) +} + +export * as WithInstance from "./with-instance" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cb85a2d276..c6c9ecaf9f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -138,6 +138,14 @@ function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } +function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + function custom(dep: CustomDep): Record { return { anthropic: () => @@ -222,12 +230,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { resourceName: resource, @@ -247,12 +250,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts new file mode 100644 index 0000000000..b5e5747c51 --- /dev/null +++ b/packages/opencode/src/pty/ticket.ts @@ -0,0 +1,68 @@ +export * as PtyTicket from "./ticket" + +import { WorkspaceID } from "@/control-plane/schema" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { PtyID } from "@/pty/schema" +import { PositiveInt } from "@/util/schema" +import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" + +const DEFAULT_TTL = Duration.seconds(60) +const CAPACITY = 10_000 + +export const ConnectToken = Schema.Struct({ + ticket: Schema.String, + expires_in: PositiveInt, +}) + +export type Scope = { + readonly ptyID: PtyID + readonly directory?: string + readonly workspaceID?: WorkspaceID +} + +export interface Interface { + issue(input: Scope): Effect.Effect + consume(input: Scope & { readonly ticket: string }): Effect.Effect +} + +export class Service extends Context.Service()("@opencode/PtyTicket") {} + +function matches(record: Scope, input: Scope) { + return ( + record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID + ) +} + +// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is +// never invoked; it dies if it ever is, which would signal a misuse of the Service interface. +const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get") + +// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL. +export const make = (ttl: Duration.Input = DEFAULT_TTL) => + Effect.gen(function* () { + const cache = yield* Cache.make({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl }) + const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl)))) + return Service.of({ + issue: Effect.fn("PtyTicket.issue")(function* (input) { + const ticket = crypto.randomUUID() + yield* Cache.set(cache, ticket, input) + return { ticket, expires_in: expiresIn } + }), + consume: Effect.fn("PtyTicket.consume")(function* (input) { + return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input)) + }), + }) + }) + +export const layer = Layer.effect(Service, make()) + +export const defaultLayer = layer + +export const scope = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { + directory: instance?.directory, + workspaceID, + } +}) diff --git a/packages/opencode/src/server/auth.ts b/packages/opencode/src/server/auth.ts new file mode 100644 index 0000000000..94d30d0192 --- /dev/null +++ b/packages/opencode/src/server/auth.ts @@ -0,0 +1,48 @@ +export * as ServerAuth from "./auth" + +import { ConfigService } from "@/effect/config-service" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Config as EffectConfig, Context, Option, Redacted } from "effect" + +export type Credentials = { + password?: string + username?: string +} + +export type DecodedCredentials = { + readonly username: string + readonly password: Redacted.Redacted +} + +export class Config extends ConfigService.Service()("@opencode/ServerAuthConfig", { + password: EffectConfig.string("KILO_SERVER_PASSWORD").pipe(EffectConfig.option), + username: EffectConfig.string("KILO_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), +}) {} + +export type Info = Context.Service.Shape + +export function required(config: Info) { + return Option.isSome(config.password) && config.password.value !== "" +} + +export function authorized(credentials: DecodedCredentials, config: Info) { + return ( + Option.isSome(config.password) && + credentials.username === config.username && + Redacted.value(credentials.password) === config.password.value + ) +} + +export function header(credentials?: Credentials) { + const password = credentials?.password ?? Flag.KILO_SERVER_PASSWORD + if (!password) return undefined + + const username = credentials?.username ?? Flag.KILO_SERVER_USERNAME ?? "opencode" + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +export function headers(credentials?: Credentials) { + const authorization = header(credentials) + if (!authorization) return undefined + return { Authorization: authorization } +} diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts index 62a181af3a..92296a3b7d 100644 --- a/packages/opencode/src/server/cors.ts +++ b/packages/opencode/src/server/cors.ts @@ -1,7 +1,13 @@ +import { Context } from "effect" + const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ export type CorsOptions = { readonly cors?: ReadonlyArray } +export const CorsConfig = Context.Reference("@opencode/ServerCorsConfig", { + defaultValue: () => undefined, +}) + export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { if (!input) return true if (input.startsWith("http://localhost:")) return true @@ -12,3 +18,17 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption if (opencodeOrigin.test(input)) return true return opts?.cors?.includes(input) ?? false } + +export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) { + if (!input) return true + if (host && sameHost(input, host)) return true + return isAllowedCorsOrigin(input, opts) +} + +function sameHost(origin: string, host: string) { + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts index 7c5861d919..506e798187 100644 --- a/packages/opencode/src/server/error.ts +++ b/packages/opencode/src/server/error.ts @@ -21,6 +21,9 @@ export const ERRORS = { }, }, }, + 403: { + description: "Forbidden", + }, 404: { description: "Not found", content: { diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index b42343a59f..1b8c42c899 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -1,78 +1,8 @@ import type { MiddlewareHandler } from "hono" -import { Database } from "@/storage/db" -import { inArray } from "drizzle-orm" -import { EventSequenceTable } from "@/sync/event.sql" -import { Workspace } from "@/control-plane/workspace" -import type { WorkspaceID } from "@/control-plane/schema" import * as Log from "@opencode-ai/core/util/log" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" +import { HEADER, diff, load } from "./shared/fence" -const HEADER = "x-kilo-sync" -type State = Record -const log = Log.create({ service: "fence" }) - -export function load(ids?: string[]) { - const rows = Database.use((db) => { - if (!ids?.length) { - return db.select().from(EventSequenceTable).all() - } - - return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() - }) - - return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State -} - -export function diff(prev: State, next: State) { - const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) - return Object.fromEntries( - [...ids] - .map((id) => [id, next[id] ?? -1] as const) - .filter(([id, seq]) => { - return (prev[id] ?? -1) !== seq - }), - ) as State -} - -export function parse(headers: Headers) { - const raw = headers.get(HEADER) - if (!raw) return - - let data - - try { - data = JSON.parse(raw) - } catch { - return - } - - if (!data || typeof data !== "object") return - - return Object.fromEntries( - Object.entries(data).filter(([id, seq]) => { - return typeof id === "string" && Number.isInteger(seq) - }), - ) as State -} - -export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - return Effect.gen(function* () { - log.info("waiting for state", { - workspaceID, - state, - }) - yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) - log.info("state fully synced", { - workspaceID, - state, - }) - }) -} - -export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) -} +const log = Log.create({ service: "fence-middleware" }) export const FenceMiddleware: MiddlewareHandler = async (c, next) => { if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next() diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts new file mode 100644 index 0000000000..aa761a42b4 --- /dev/null +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -0,0 +1,37 @@ +import { GlobalBus } from "@/bus/global" +import { InstanceStore } from "@/project/instance-store" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { Event } from "./event" + +const log = Log.create({ service: "server" }) + +export const emitGlobalDisposed = Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }), +) + +export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")( + function* (options?: { swallowErrors?: boolean }) { + const store = yield* InstanceStore.Service + yield* Effect.gen(function* () { + yield* options?.swallowErrors + ? store.disposeAll().pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("global disposal failed", { cause }) + }), + ), + ) + : store.disposeAll() + yield* emitGlobalDisposed + }).pipe(Effect.uninterruptible) + }, +) + +export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/src/server/httpapi-server.node.ts b/packages/opencode/src/server/httpapi-server.node.ts new file mode 100644 index 0000000000..5d29fae33f --- /dev/null +++ b/packages/opencode/src/server/httpapi-server.node.ts @@ -0,0 +1,34 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import type { Opts } from "./adapter" +import { Service } from "./httpapi-server" + +export { Service } + +export const name = "node-http-server" + +export const layer = (opts: Opts) => { + const server = createServer() + const serverRef = { closeStarted: false, forceStop: false } + const close = server.close.bind(server) + // Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by + // force-closing active HTTP sockets when its finalizer calls server.close(). + server.close = ((callback?: Parameters[0]) => { + serverRef.closeStarted = true + const result = close(callback) + if (serverRef.forceStop) server.closeAllConnections() + return result + }) as typeof server.close + return Layer.mergeAll( + NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }), + Layer.succeed(Service)( + Service.of({ + closeAll: Effect.sync(() => { + serverRef.forceStop = true + if (serverRef.closeStarted) server.closeAllConnections() + }), + }), + ), + ) +} diff --git a/packages/opencode/src/server/httpapi-server.ts b/packages/opencode/src/server/httpapi-server.ts new file mode 100644 index 0000000000..5f3804c107 --- /dev/null +++ b/packages/opencode/src/server/httpapi-server.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly closeAll: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HttpApiServer") {} + +export * as HttpApiServer from "./httpapi-server" diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 47ce65147e..88b1a9b961 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -12,6 +12,8 @@ import { cors } from "hono/cors" import { compress } from "hono/compress" import * as ServerBackend from "./backend" import { isAllowedCorsOrigin, type CorsOptions } from "./cors" +import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" +import { isPublicUIPath } from "./shared/public-ui" const log = Log.create({ service: "server" }) @@ -44,6 +46,8 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { if (c.req.method === "OPTIONS") return next() const password = Flag.KILO_SERVER_PASSWORD if (!password) return next() + if (isPublicUIPath(c.req.method, c.req.path)) return next() + if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() const username = Flag.KILO_SERVER_USERNAME ?? "opencode" if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) @@ -58,6 +62,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M const attributes = { method: c.req.method, path: c.req.path, + // If this logger grows full-URL fields, redact auth_token and ticket query params. ...backendAttributes, } log.info("request", attributes) diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 15535f9d97..4001356add 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" import * as Log from "@opencode-ai/core/util/log" -import * as Fence from "./fence" +import * as Fence from "./shared/fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index f40a584536..da3614d228 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -1,25 +1,24 @@ import { Hono, type Context } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import { streamSSE } from "hono/streaming" -import { Effect, Schema } from "effect" +import { Effect } from "effect" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" +import { Bus } from "@/bus" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" -import { InstanceStore } from "../../project/instance-store" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { lazy } from "../../util/lazy" import { Config } from "@/config/config" import { errors } from "../error" +import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle" const log = Log.create({ service: "server" }) -export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({})) - async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { return streamSSE(c, async (stream) => { const q = new AsyncQueue() @@ -28,6 +27,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.connected", properties: {}, }, @@ -39,6 +39,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.heartbeat", properties: {}, }, @@ -178,8 +179,13 @@ export const GlobalRoutes = lazy(() => validator("json", Config.Info.zod), async (c) => { const config = c.req.valid("json") - const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) - return c.json(next) + const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) + if (result.changed) { + void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch( + () => undefined, + ) + } + return c.json(result.info) }, ) .post( @@ -200,14 +206,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await InstanceStore.disposeAllInstances() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: GlobalDisposedEvent.type, - properties: {}, - }, - }) + await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed()) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index f055917b0c..949734f81a 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -1,11 +1,16 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "@/project/instance-store" import { Provider } from "@/provider/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" +import { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server.config" }) export const ConfigRoutes = lazy(() => new Hono() @@ -51,13 +56,28 @@ export const ConfigRoutes = lazy(() => }, }), validator("json", Config.Info.zod), - async (c) => - jsonRequest("ConfigRoutes.update", c, function* () { - const config = c.req.valid("json") - const cfg = yield* Config.Service - yield* cfg.update(config) - return config - }), + async (c) => { + const result = await runRequest( + "ConfigRoutes.update", + c, + Effect.gen(function* () { + const config = c.req.valid("json") + const cfg = yield* Config.Service + yield* cfg.update(config) + return { config, ctx: yield* InstanceState.context } + }), + ) + const response = c.json(result.config) + void runRequest( + "ConfigRoutes.update.dispose", + c, + InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe( + Effect.uninterruptible, + Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), + ), + ) + return response + }, ) .get( "/providers", diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts index 474d92b31b..aeb1da5393 100644 --- a/packages/opencode/src/server/routes/instance/event.ts +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -42,6 +42,7 @@ export const EventRoutes = () => q.push( JSON.stringify({ + id: Bus.createID(), type: "server.connected", properties: {}, }), @@ -51,6 +52,7 @@ export const EventRoutes = () => const heartbeat = setInterval(() => { q.push( JSON.stringify({ + id: Bus.createID(), type: "server.heartbeat", properties: {}, }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 81ea2394c0..1cf1584e3e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -19,6 +19,7 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" +import { V2Api } from "./groups/v2" // SSE event schemas built from the same BusEvent/SyncEvent registries that // the Hono spec uses, so both specs emit identical Event/SyncEvent components. @@ -40,6 +41,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ProviderApi) .addHttpApi(SessionApi) .addHttpApi(SyncApi) + .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 25e810753e..a5c328ac0e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -41,12 +41,12 @@ function eventResponse(bus: Bus.Interface) { const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ type: "server.heartbeat", properties: {} })), + Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })), ) log.info("event connected") return HttpServerResponse.stream( - Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 272b086065..75441b4ca4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" +import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index d54bda4a84..3304ab9fbf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -1,4 +1,5 @@ import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { PtyID } from "@/pty/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -23,6 +24,7 @@ export const PtyPaths = { get: `${root}/:ptyID`, update: `${root}/:ptyID`, remove: `${root}/:ptyID`, + connectToken: `${root}/:ptyID/connect-token`, connect: `${root}/:ptyID/connect`, } as const @@ -93,6 +95,17 @@ export const PtyApi = HttpApi.make("pty") description: "Remove and terminate a specific pseudo-terminal (PTY) session.", }), ), + HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { + params: { ptyID: PtyID }, + success: described(PtyTicket.ConnectToken, "WebSocket connect token"), + error: [HttpApiError.Forbidden, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived ticket for opening a PTY WebSocket connection.", + }), + ), ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) .middleware(InstanceContextMiddleware) @@ -113,7 +126,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, success: described(Schema.Boolean, "Connected session"), - error: HttpApiError.NotFound, + error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "pty.connect", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts new file mode 100644 index 0000000000..05da5b720d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -0,0 +1,14 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { MessageGroup } from "./v2/message" +import { SessionGroup } from "./v2/session" + +export const V2Api = HttpApi.make("v2") + .add(SessionGroup) + .add(MessageGroup) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts new file mode 100644 index 0000000000..3b0b2fa5b1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -0,0 +1,69 @@ +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const MessageGroup = HttpApiGroup.make("v2.message") + .add( + HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { + params: { sessionID: SessionID }, + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionMessagesQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionMessagesResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get v2 session messages", + description: + "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 messages", + description: "Experimental v2 message routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts new file mode 100644 index 0000000000..17ddcaeda3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -0,0 +1,140 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Prompt } from "@/v2/session-prompt" +import { SessionV2 } from "@/v2/session" +import { Schema, SchemaGetter } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const SessionGroup = HttpApiGroup.make("v2.session") + .add( + HttpApiEndpoint.get("sessions", "/api/session", { + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspace: WorkspaceID.pipe(Schema.optional), + roots: Schema.Literals(["true", "false"]) + .pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), + ) + .pipe(Schema.optional), + start: Schema.NumberFromString.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + directory: Schema.optional(Schema.Never), + path: Schema.optional(Schema.Never), + workspace: Schema.optional(Schema.Never), + roots: Schema.optional(Schema.Never), + start: Schema.optional(Schema.Never), + search: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionsQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionV2.Info), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionsResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.list", + summary: "List v2 sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ + prompt: Prompt, + delivery: SessionV2.Delivery.pipe(Schema.optional), + }), + success: SessionMessage.Message, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send v2 message", + description: "Create a v2 session message and queue it for the agent loop.", + }), + ), + ) + .add( + HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact v2 session", + description: "Compact a v2 session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for v2 session", + description: "Wait for a v2 session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { + params: { sessionID: SessionID }, + success: Schema.Array(SessionMessage.Message), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get v2 session context", + description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2", + description: "Experimental v2 routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 58aa81098c..753ba03138 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -16,7 +16,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h }) const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { - yield* configSvc.update(ctx.payload, { dispose: false }) + yield* configSvc.update(ctx.payload) yield* markInstanceForDisposal(yield* InstanceState.context) return ctx.payload }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index bcad2832e2..f80869b64d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,9 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" +import { EffectBridge } from "@/effect/bridge" +import { Bus } from "@/bus" import { Installation } from "@/installation" -import { InstanceStore } from "@/project/instance-store" +import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { Effect, Queue, Schema } from "effect" @@ -42,11 +44,11 @@ function eventResponse() { }) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), + Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })), ) return HttpServerResponse.stream( - Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( + Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), @@ -68,7 +70,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl Effect.gen(function* () { const config = yield* Config.Service const installation = yield* Installation.Service - const store = yield* InstanceStore.Service + const bridge = yield* EffectBridge.make() const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -83,15 +85,13 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { - return yield* config.updateGlobal(ctx.payload) + const result = yield* config.updateGlobal(ctx.payload) + if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })) + return result.info }) const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* store.disposeAll() - GlobalBus.emit("event", { - directory: "global", - payload: { type: "global.disposed", properties: {} }, - }) + yield* disposeAllInstancesAndEmitGlobalDisposed() return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index cc7c385b3e..e5ff300a2a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,17 +1,32 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { handlePtyInput } from "@/pty/input" import { Shell } from "@/shell/shell" +import { EffectBridge } from "@/effect/bridge" +import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InstanceHttpApi } from "../api" import { CursorQuery, Params, PtyPaths } from "../groups/pty" +import { WebSocketTracker } from "../websocket-tracker" + +function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) { + return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts) +} export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig const shells = Effect.fn("PtyHttpApi.shells")(function* () { return yield* Effect.promise(() => Shell.list()) @@ -52,6 +67,14 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler return true }) + const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) { + const request = yield* HttpServerRequest.HttpServerRequest + if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) + return yield* new HttpApiError.Forbidden({}) + if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({}) + return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) + }) + return handlers .handle("shells", shells) .handle("list", list) @@ -59,12 +82,15 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("get", get) .handle("update", update) .handle("remove", remove) + .handle("connectToken", connectToken) }), ) export const ptyConnectRoute = HttpRouter.use((router) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig yield* router.add( "GET", PtyPaths.connect, @@ -73,16 +99,37 @@ export const ptyConnectRoute = HttpRouter.use((router) => if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + const request = yield* HttpServerRequest.HttpServerRequest + const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + const valid = validOrigin(request, cors) + ? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) }) + : false + if (!valid) return HttpServerResponse.empty({ status: 403 }) + } const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) const cursor = parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) + const socket = yield* Effect.orDie(request.upgrade) const write = yield* socket.writer - const services = yield* Effect.context() + const closeAccepted = (event: Socket.CloseEvent) => + socket + .runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT())) + if (!registered) { + yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT()) + return HttpServerResponse.empty() + } + const bridge = yield* EffectBridge.make() const writeScoped = (effect: Effect.Effect) => { - Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void))) + bridge.fork(effect.pipe(Effect.catch(() => Effect.void))) } let closed = false const adapter = { @@ -100,7 +147,10 @@ export const ptyConnectRoute = HttpRouter.use((router) => }, } const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() + if (!handler) { + yield* closeAccepted(new Socket.CloseEvent(4404, "session not found")) + return HttpServerResponse.empty() + } yield* socket .runRaw((message) => handlePtyInput(handler, message)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 8cc969f483..4a67ba036e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -18,7 +18,7 @@ import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { NamedError } from "@opencode-ai/core/util/error" -import { Cause, Effect, Schema, Scope } from "effect" +import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -125,7 +125,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (!page.cursor) return page.items const request = yield* HttpServerRequest.HttpServerRequest - const url = new URL(request.url, "http://localhost") + // toURL() honors the Host + x-forwarded-proto headers, so the Link + // header echoes the real origin instead of a hard-coded localhost. + const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) url.searchParams.set("limit", ctx.query.limit.toString()) url.searchParams.set("before", page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index c7c447ce85..cc85321685 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -5,7 +5,7 @@ import * as Database from "@/storage/db" import { eq } from "drizzle-orm" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" -import { nextTuiRequest, submitTuiResponse } from "../../tui" +import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control" import { InstanceHttpApi } from "../api" import { CommandPayload, TuiPublishPayload } from "../groups/tui" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts new file mode 100644 index 0000000000..55cb534581 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -0,0 +1,6 @@ +import { SessionV2 } from "@/v2/session" +import { Layer } from "effect" +import { messageHandlers } from "./v2/message" +import { sessionHandlers } from "./v2/session" + +export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts new file mode 100644 index 0000000000..3485d80fd6 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -0,0 +1,60 @@ +import { SessionMessage } from "@/v2/session-message" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import * as DateTime from "effect/DateTime" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultMessagesLimit = 50 + +const Cursor = Schema.Struct({ + id: SessionMessage.ID, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), +}) + +const decodeCursor = Schema.decodeUnknownSync(Cursor) + +const cursor = { + encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + return Buffer.from( + JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), order, direction }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers.handle( + "messages", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const messages = yield* session.messages({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit ?? DefaultMessagesLimit, + order, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = messages[0] + const last = messages.at(-1) + return { + items: messages, + cursor: { + previous: first ? cursor.encode(first, order, "previous") : undefined, + next: last ? cursor.encode(last, order, "next") : undefined, + }, + } + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts new file mode 100644 index 0000000000..558e34dd18 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -0,0 +1,115 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultSessionsLimit = 50 + +const SessionCursor = Schema.Struct({ + id: SessionV2.Info.fields.id, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspaceID: WorkspaceID.pipe(Schema.optional), + roots: Schema.Boolean.pipe(Schema.optional), + start: Schema.Finite.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), +}) +type SessionCursor = typeof SessionCursor.Type + +const decodeCursor = Schema.decodeUnknownSync(SessionCursor) + +const sessionCursor = { + encode( + session: SessionV2.Info, + order: "asc" | "desc", + direction: "previous" | "next", + filters: Pick, + ) { + return Buffer.from( + JSON.stringify({ id: session.id, time: session.time.created, order, direction, ...filters }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers + .handle( + "sessions", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const filters = decoded ?? { + directory: ctx.query.directory, + path: ctx.query.path, + workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined, + roots: ctx.query.roots, + start: ctx.query.start, + search: ctx.query.search, + } + const sessions = yield* session.list({ + limit: ctx.query.limit ?? DefaultSessionsLimit, + order, + directory: filters.directory, + path: filters.path, + workspaceID: filters.workspaceID, + roots: filters.roots, + start: filters.start, + search: filters.search, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + items: sessions, + cursor: { + previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined, + next: last ? sessionCursor.encode(last, order, "next", filters) : undefined, + }, + } + }), + ) + .handle( + "prompt", + Effect.fn(function* (ctx) { + return yield* session.prompt({ + sessionID: ctx.params.sessionID, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, + }) + }), + ) + .handle( + "compact", + Effect.fn(function* (ctx) { + yield* session.compact(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "wait", + Effect.fn(function* (ctx) { + yield* session.wait(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "context", + Effect.fn(function* (ctx) { + return yield* session.context(ctx.params.sessionID) + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 3194454332..6f5648f30a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,71 +1,52 @@ -import { ConfigService } from "@/effect/config-service" -import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect" +import { ServerAuth } from "@/server/auth" +import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" +import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" +import { isPublicUIPath } from "@/server/shared/public-ui" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 +const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' +// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the +// full handler, so a downstream failure can make the next auth alternative run +// and remap an authorized NotFound into Unauthorized. export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { error: HttpApiError.UnauthorizedNoContent, - security: { - basic: HttpApiSecurity.basic, - authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }), - }, }, ) {} -export class ServerAuthConfig extends ConfigService.Service()( - "@opencode/ExperimentalHttpApiServerAuthConfig", - { - password: Config.string("KILO_SERVER_PASSWORD").pipe(Config.option), - username: Config.string("KILO_SERVER_USERNAME").pipe(Config.withDefault("opencode")), - }, -) {} +function emptyCredential() { + return { + username: "", + password: Redacted.make(""), + } +} function validateCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, + credential: ServerAuth.DecodedCredentials, + config: ServerAuth.Info, ) { return Effect.gen(function* () { - if (!isAuthRequired(config)) return yield* effect - if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) + if (!ServerAuth.required(config)) return yield* effect + if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) return yield* effect }) } -function isAuthRequired(config: Context.Service.Shape) { - return Option.isSome(config.password) && config.password.value !== "" -} - -function isCredentialAuthorized( - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, -) { - return ( - Option.isSome(config.password) && - credential.username === config.username && - Redacted.value(credential.password) === config.password.value - ) -} - function decodeCredential(input: string) { - const emptyCredential = { - username: "", - password: Redacted.make(""), - } - return Encoding.decodeBase64String(input) .asEffect() .pipe( Effect.match({ - onFailure: () => emptyCredential, + onFailure: emptyCredential, onSuccess: (header) => { const parts = header.split(":") - if (parts.length !== 2) return emptyCredential + if (parts.length !== 2) return emptyCredential() return { username: parts[0], password: Redacted.make(parts[1]), @@ -75,40 +56,48 @@ function decodeCredential(input: string) { ) } +function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { + return credentialFromURL(new URL(request.url, "http://localhost"), request) +} + +function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) { + const token = url.searchParams.get(AUTH_TOKEN_QUERY) + if (token) return decodeCredential(token) + const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") + if (match) return decodeCredential(match[1]) + return Effect.succeed(emptyCredential()) +} + function validateRawCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, + credential: ServerAuth.DecodedCredentials, + config: ServerAuth.Info, ) { - if (!isAuthRequired(config)) return effect - if (!isCredentialAuthorized(credential, config)) - return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED })) + if (!ServerAuth.required(config)) return effect + if (!ServerAuth.authorized(credential, config)) + return Effect.succeed( + HttpServerResponse.empty({ + status: UNAUTHORIZED, + headers: { "www-authenticate": WWW_AUTHENTICATE }, + }), + ) return effect } export const authorizationRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { - const config = yield* ServerAuthConfig - if (!isAuthRequired(config)) return (effect) => effect + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return (effect) => effect return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") - if (match) { - return yield* decodeCredential(match[1]).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), - ) - } - - const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) - if (token) { - return yield* decodeCredential(token).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), - ) - } - - return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config) + const url = new URL(request.url, "http://localhost") + if (isPublicUIPath(request.method, url.pathname)) return yield* effect + if (hasPtyConnectTicketURL(url)) return yield* effect + return yield* credentialFromURL(url, request).pipe( + Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + ) }) }), ) @@ -116,13 +105,15 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { - const config = yield* ServerAuthConfig - return Authorization.of({ - basic: (effect, { credential }) => validateCredential(effect, credential, config), - authToken: (effect, { credential }) => - decodeCredential(Redacted.value(credential)).pipe( - Effect.flatMap((decoded) => validateCredential(effect, decoded, config)), - ), - }) + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return Authorization.of((effect) => effect) + return Authorization.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* credentialFromRequest(request).pipe( + Effect.flatMap((credential) => validateCredential(effect, credential, config)), + ) + }), + ) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts new file mode 100644 index 0000000000..6f3c33a647 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -0,0 +1,58 @@ +import { Provider } from "@/provider/provider" +import { Session } from "@/session/session" +import { NotFoundError } from "@/storage/storage" +import { iife } from "@/util/iife" +import { NamedError } from "@opencode-ai/core/util/error" +import * as Log from "@opencode-ai/core/util/log" +import { Cause, Effect } from "effect" +import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" + +const log = Log.create({ service: "server" }) + +// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. +export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + effect.pipe( + Effect.catchCause((cause) => { + const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { + if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false + if (HttpServerError.isHttpServerError(reason.defect)) return false + if (HttpServerRespondable.isRespondable(reason.defect)) return false + return true + }) + if (!defect) return Effect.failCause(cause) + + const error = defect.defect + log.error("failed", { error, cause: Cause.pretty(cause) }) + + if (error instanceof NamedError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(error.toObject(), { + status: iife(() => { + if (error instanceof NotFoundError) return 404 + if (error instanceof Provider.ModelNotFoundError) return 400 + if (error.name === "ProviderAuthValidationFailed") return 400 + if (error.name.startsWith("Worktree")) return 400 + return 500 + }), + }), + ) + } + if (error instanceof Session.BusyError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), { + status: 400, + }), + ) + } + + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + new NamedError.Unknown({ + message: error instanceof Error && error.stack ? error.stack : String(error), + }).toObject(), + { status: 500 }, + ), + ) + }), + ), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 0e82da31b3..d4913696d2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,5 +1,4 @@ import { WorkspaceRef } from "@/effect/instance-ref" -import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -24,12 +23,11 @@ function decode(input: string): string { function provideInstanceContext( effect: Effect.Effect, store: InstanceStore.Interface, - bootstrap: InstanceBootstrap.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext return yield* store.provide( - { directory: decode(route.directory), init: bootstrap.run }, + { directory: decode(route.directory) }, effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) @@ -39,15 +37,13 @@ export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, Effect.gen(function* () { const store = yield* InstanceStore.Service - const bootstrap = yield* InstanceBootstrap.Service - return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap)) + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) }), ) export const instanceRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const store = yield* InstanceStore.Service - const bootstrap = yield* InstanceBootstrap.Service - return (effect) => provideInstanceContext(effect, store, bootstrap) + return (effect) => provideInstanceContext(effect, store) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index e354dccbfa..230f5b105b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -2,6 +2,7 @@ import { ProxyUtil } from "@/server/proxy-util" import { Effect, Stream } from "effect" import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { WebSocketTracker } from "../websocket-tracker" function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { return request.source instanceof Request ? request.source : undefined @@ -28,6 +29,33 @@ export function websocket( }) const writeInbound = yield* inbound.writer const writeOutbound = yield* outbound.writer + const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect) => + socket + .runRaw(() => Effect.void, { + onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)), + }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + const closeAccepted = Effect.all([closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], { + concurrency: "unbounded", + discard: true, + }) + const registered = yield* WebSocketTracker.register( + Effect.all( + [ + writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), + writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT()), + ], + { concurrency: "unbounded", discard: true }, + ), + ) + if (!registered) { + yield* closeAccepted + return HttpServerResponse.empty() + } yield* outbound .runRaw((message) => writeInbound(message)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 5d3932a942..6de6d9c4fb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -5,8 +5,8 @@ import { Workspace } from "@/control-plane/workspace" import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" -import * as Fence from "@/server/fence" -import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" +import * as Fence from "@/server/shared/fence" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 767bfc31db..ef966036a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -18,14 +18,14 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" -import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceStore } from "@/project/instance-store" +import { InstanceLayer } from "@/project/instance-layer" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Question } from "@/question" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -45,10 +45,11 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" -import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" -import { serveUIEffect } from "@/server/routes/ui" +import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { serveUIEffect } from "@/server/shared/ui" +import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" -import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" +import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -65,12 +66,14 @@ import { questionHandlers } from "./handlers/question" import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" +import { v2Handlers } from "./handlers/v2" import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" +import { errorLayer } from "./middleware/error" export const context = Context.makeUnsafe(new Map()) @@ -97,7 +100,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont const instanceRouterLayer = authorizationRouterMiddleware .combine(instanceRouterMiddleware) .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer)) const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe( Layer.provide(eventHandlers), Layer.provide(instanceRouterLayer), @@ -116,6 +119,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( providerHandlers, sessionHandlers, syncHandlers, + v2Handlers, tuiHandlers, workspaceHandlers, ]), @@ -124,7 +128,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), + authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)), workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, ]), @@ -136,11 +140,12 @@ const uiRoute = HttpRouter.use((router) => const client = yield* HttpClient.HttpClient yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), -).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) +).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)))) export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ + errorLayer, cors(corsOptions), runtime, Account.defaultLayer, @@ -153,8 +158,6 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, MCP.defaultLayer, ModelsDev.defaultLayer, Permission.defaultLayer, @@ -163,6 +166,7 @@ export function createRoutes(corsOptions?: CorsOptions) { ProviderAuth.defaultLayer, Provider.defaultLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Question.defaultLayer, Ripgrep.defaultLayer, Session.defaultLayer, @@ -181,12 +185,14 @@ export function createRoutes(corsOptions?: CorsOptions) { ToolRegistry.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Bus.layer, AppFileSystem.defaultLayer, FetchHttpClient.layer, HttpServer.layerServices, ]), + Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)), + Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer), ) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts new file mode 100644 index 0000000000..7cbac4ed5f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -0,0 +1,57 @@ +import { Context, Effect, Layer, Option } from "effect" +import * as Socket from "effect/unstable/socket/Socket" + +export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") + +type Close = Effect.Effect + +export interface Interface { + readonly add: (close: Close) => Effect.Effect + readonly remove: (close: Close) => Effect.Effect + readonly closeAll: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HttpApiWebSocketTracker") {} + +export const layer = Layer.sync(Service)(() => { + const sockets = new Set() + let closing = false + return Service.of({ + add: (close) => + Effect.gen(function* () { + if (closing) return false + sockets.add(close) + return true + }), + remove: (close) => + Effect.sync(() => { + sockets.delete(close) + }), + closeAll: Effect.gen(function* () { + closing = true + const active = Array.from(sockets) + sockets.clear() + yield* Effect.all( + active.map((close) => + close.pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.void), + ), + ), + { concurrency: "unbounded", discard: true }, + ) + }), + }) +}) + +export const register = (close: Close) => + Effect.gen(function* () { + const tracker = yield* Effect.serviceOption(Service) + if (Option.isNone(tracker)) return true + const registered = yield* tracker.value.add(close) + if (!registered) return false + yield* Effect.addFinalizer(() => tracker.value.remove(close)) + return true + }) + +export * as WebSocketTracker from "./websocket-tracker" diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 530c02345a..e3bd7c4557 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -1,12 +1,13 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { Effect } from "effect" +import { Context, Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Vcs } from "@/project/vcs" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" @@ -27,13 +28,139 @@ import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" import { InstanceMiddleware } from "./middleware" import { jsonRequest } from "./trace" +import { ExperimentalHttpApiServer } from "./httpapi/server" +import { EventPaths } from "./httpapi/event" +import { ExperimentalPaths } from "./httpapi/groups/experimental" +import { FilePaths } from "./httpapi/groups/file" +import { InstancePaths } from "./httpapi/groups/instance" +import { McpPaths } from "./httpapi/groups/mcp" +import { PtyPaths } from "./httpapi/groups/pty" +import { SessionPaths } from "./httpapi/groups/session" +import { SyncPaths } from "./httpapi/groups/sync" +import { TuiPaths } from "./httpapi/groups/tui" +import { WorkspacePaths } from "./httpapi/groups/workspace" +import type { CorsOptions } from "@/server/cors" -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { +export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { const app = new Hono() + const handler = ExperimentalHttpApiServer.webHandler(opts).handler + const context = Context.empty() as Context.Context + + app.all("/api/*", (c) => handler(c.req.raw, context)) + + if (Flag.KILO_EXPERIMENTAL_HTTPAPI) { + app.get(EventPaths.event, (c) => handler(c.req.raw, context)) + app.get("/question", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) + app.get("/permission", (c) => handler(c.req.raw, context)) + app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) + app.patch("/config", (c) => handler(c.req.raw, context)) + app.get("/config/providers", (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) + app.get("/provider", (c) => handler(c.req.raw, context)) + app.get("/provider/auth", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) + app.get("/project", (c) => handler(c.req.raw, context)) + app.get("/project/current", (c) => handler(c.req.raw, context)) + app.post("/project/git/init", (c) => handler(c.req.raw, context)) + app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) + app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) + app.get(FilePaths.list, (c) => handler(c.req.raw, context)) + app.get(FilePaths.content, (c) => handler(c.req.raw, context)) + app.get(FilePaths.status, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) + app.get(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) + app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) + app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) + app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) + app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) + app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) + app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) + } return app .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade)) + .route("/pty", PtyRoutes(upgrade, opts)) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) @@ -63,7 +190,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index f8a330fea2..40b81cd446 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono" -import { Instance } from "@/project/instance" -import { getBootstrapRunEffect } from "@/effect/app-runtime" +import { WithInstance } from "@/project/with-instance" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -21,9 +20,8 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler return WorkspaceContext.provide({ workspaceID, async fn() { - return Instance.provide({ + return WithInstance.provide({ directory, - init: await getBootstrapRunEffect(), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index dbca75c195..3d8bb605bd 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -2,13 +2,12 @@ import { Hono } from "hono" import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Project } from "@/project/project" import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { getBootstrapRunEffect } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => @@ -82,7 +81,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next, init: await getBootstrapRunEffect() }) + await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next }) return c.json(next) }, ) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index bff0b71915..fb8d5e356d 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import type { Context } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import type { UpgradeWebSocket } from "hono/ws" import { Effect, Schema } from "effect" @@ -6,10 +7,19 @@ import z from "zod" import { AppRuntime } from "@/effect/app-runtime" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { Shell } from "@/shell/shell" import { NotFoundError } from "@/storage/storage" import { errors } from "../../error" import { jsonRequest, runRequest } from "./trace" +import { HTTPException } from "hono/http-exception" +import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" +import { zod as effectZod } from "@/util/effect-zod" const ShellItem = z.object({ path: z.string(), @@ -18,7 +28,11 @@ const ShellItem = z.object({ }) const decodePtyID = Schema.decodeUnknownSync(PtyID) -export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { +function validOrigin(c: Context, opts?: CorsOptions) { + return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts) +} + +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) { return new Hono() .get( "/shells", @@ -175,6 +189,43 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { return true }), ) + .post( + "/:ptyID/connect-token", + describeRoute({ + summary: "Create PTY WebSocket token", + description: "Create a short-lived token for opening a PTY WebSocket connection.", + operationId: "pty.connectToken", + responses: { + 200: { + description: "WebSocket connect token", + content: { + "application/json": { + schema: resolver(effectZod(PtyTicket.ConnectToken)), + }, + }, + }, + ...errors(403, 404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts)) + throw new HTTPException(403) + const result = await runRequest( + "PtyRoutes.connectToken", + c, + Effect.gen(function* () { + const pty = yield* Pty.Service + const id = c.req.valid("param").ptyID + if (!(yield* pty.get(id))) return + const tickets = yield* PtyTicket.Service + return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!result) throw new NotFoundError({ message: "Session not found" }) + return c.json(result) + }, + ) .get( "/:ptyID/connect", describeRoute({ @@ -190,7 +241,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }, }, - ...errors(404), + ...errors(403, 404), }, }), validator("param", z.object({ ptyID: PtyID.zod })), @@ -201,14 +252,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { } const id = decodePtyID(c.req.param("ptyID")) - const cursor = (() => { - const value = c.req.query("cursor") - if (!value) return - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return - return parsed - })() - let handler: Handler | undefined if ( !(await runRequest( "PtyRoutes.connect", @@ -219,8 +262,29 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), )) ) { - throw new Error("Session not found") + throw new NotFoundError({ message: "Session not found" }) } + const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + if (!validOrigin(c, opts)) throw new HTTPException(403) + const valid = await runRequest( + "PtyRoutes.connect.ticket", + c, + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!valid) throw new HTTPException(403) + } + const cursor = (() => { + const value = c.req.query("cursor") + if (!value) return + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return + return parsed + })() + let handler: Handler | undefined type Socket = { readyState: number diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 48399a5f4d..a7a0c9cbdc 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -7,36 +7,24 @@ import { Session } from "@/session/session" import type { SessionID } from "@/session/schema" import { TuiEvent } from "@/cli/cmd/tui/event" import { zodObject } from "@/util/effect-zod" -import { AsyncQueue } from "@/util/queue" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { runRequest } from "./trace" - -export const TuiRequest = z.object({ - path: z.string(), - body: z.any(), -}) - -export type TuiRequest = z.infer - -const request = new AsyncQueue() -const response = new AsyncQueue() - -export function nextTuiRequest() { - return request.next() -} - -export function submitTuiResponse(body: unknown) { - response.push(body) -} +import { + TuiRequest, + nextTuiRequest, + nextTuiResponse, + submitTuiRequest, + submitTuiResponse, +} from "@/server/shared/tui-control" export async function callTui(ctx: Context) { const body = await ctx.req.json() - request.push({ + submitTuiRequest({ path: ctx.req.path, body, }) - return response.next() + return nextTuiResponse() } const TuiControlRoutes = new Hono() diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index 213a6332d2..ce06b2b35e 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,53 +1,10 @@ -import { Flag } from "@opencode-ai/core/flag/flag" +import fs from "node:fs/promises" +import { createHash } from "node:crypto" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Effect, Stream } from "effect" -import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Hono } from "hono" import { proxy } from "hono/proxy" -import { getMimeType } from "hono/utils/mime" -import { createHash } from "node:crypto" -import fs from "node:fs/promises" import { ProxyUtil } from "../proxy-util" - -const embeddedUIPromise = Flag.KILO_DISABLE_EMBEDDED_WEB_UI - ? Promise.resolve(null) - : // @ts-expect-error - generated file at build time - import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) - -const DEFAULT_CSP = - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" -const UI_UPSTREAM = new URL("https://app.opencode.ai") - -const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` - -function themePreloadHash(body: string) { - return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) -} - -function requestBody(request: HttpServerRequest.HttpServerRequest) { - if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty - const len = request.headers["content-length"] - return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) -} - -function proxyResponseHeaders(headers: Record) { - const result = new Headers(headers) - // FetchHttpClient exposes decoded response bodies, so forwarding upstream - // transfer metadata makes browsers decode already-decoded assets again. - result.delete("content-encoding") - result.delete("content-length") - return result -} - -function upstreamURL(path: string) { - return new URL(path, UI_UPSTREAM).toString() -} - -function embeddedUI() { - if (Flag.KILO_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) - return embeddedUIPromise -} +import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui" export async function serveUI(request: Request) { const embeddedWebUI = await embeddedUI() @@ -58,7 +15,7 @@ export async function serveUI(request: Request) { if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) if (await fs.exists(match)) { - const mime = getMimeType(match) ?? "text/plain" + const mime = AppFileSystem.mimeType(match) const headers = new Headers({ "content-type": mime }) if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) return new Response(new Uint8Array(await fs.readFile(match)), { headers }) @@ -79,49 +36,4 @@ export async function serveUI(request: Request) { return response } -export function serveUIEffect( - request: HttpServerRequest.HttpServerRequest, - services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, -) { - return Effect.gen(function* () { - const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) - const path = new URL(request.url, "http://localhost").pathname - - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - - if (yield* services.fs.existsSafe(match)) { - const mime = getMimeType(match) ?? "text/plain" - const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) - } - - return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - } - - const response = yield* services.client.execute( - HttpClientRequest.make(request.method)(upstreamURL(path), { - headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), - body: requestBody(request), - }), - ) - const headers = proxyResponseHeaders(response.headers) - - if (response.headers["content-type"]?.includes("text/html")) { - const body = yield* response.text - const match = themePreloadHash(body) - headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) - return HttpServerResponse.text(body, { status: response.status, headers }) - } - - headers.set("Content-Security-Policy", csp()) - return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { - status: response.status, - headers, - }) - }) -} - export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 11bcdf5b7f..2525bcfdd0 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,6 +5,10 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" +import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import * as HttpApiServer from "#httpapi-server" import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" @@ -17,6 +21,9 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" +import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" +import { PublicApi } from "./routes/instance/httpapi/public" import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" @@ -113,7 +120,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .use(InstanceMiddleware(Flag.KILO_WORKSPACE_ID ? WorkspaceID.make(Flag.KILO_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)), + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)), runtime, } } @@ -129,13 +136,36 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .route("/", ControlPlaneRoutes()) .route("/", workspaceApp) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)) + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)) .route("/", UIRoutes()), runtime, } } +/** + * Generate the OpenAPI document used by the SDK build. + * + * Since the Effect HttpApi backend now covers every Hono route (plus the new + * `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity + * audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`. + * `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi` + * transform that injects instance query parameters, strips Effect's optional + * null arms, normalizes component names, and patches SSE response schemas so + * the generated SDK keeps the legacy Hono shape. + * + * The Hono-derived spec is still reachable via `openapiHono()` so reviewers + * can diff the two outputs while the Hono backend lingers; once the Hono + * backend is deleted that helper goes with it. + */ export async function openapi() { + return OpenApi.fromApi(PublicApi) +} + +/** + * Hono-derived OpenAPI spec, retained for parity diffing only. Delete once + * the Hono backend is removed. + */ +export async function openapiHono() { // Build a fresh app with all routes registered directly so // hono-openapi can see describeRoute metadata (`.route()` wraps // handlers when the sub-app has a custom errorHandler, which @@ -157,37 +187,151 @@ export async function openapi() { export let url: URL export async function listen(opts: ListenOptions): Promise { - const built = create(opts) - const server = await built.runtime.listen(opts) + const selected = select() + const inner: Listener = + selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts) - const next = new URL("http://localhost") - next.hostname = opts.hostname - next.port = String(server.port) + const next = new URL(inner.url) url = next const mdns = - opts.mdns && - server.port && - opts.hostname !== "127.0.0.1" && - opts.hostname !== "localhost" && - opts.hostname !== "::1" + opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (mdns) { - MDNS.publish(server.port, opts.mdnsDomain) + MDNS.publish(inner.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } let closing: Promise | undefined + let mdnsUnpublished = false + const unpublish = () => { + if (!mdns || mdnsUnpublished) return + mdnsUnpublished = true + MDNS.unpublish() + } + return { + hostname: inner.hostname, + port: inner.port, + url: next, + stop(close?: boolean) { + unpublish() + // Always forward stop(true), even if a graceful stop was requested + // first, so native listeners can escalate shutdown in-place. + const next = inner.stop(close) + closing ??= next + return close ? next.then(() => closing!) : closing + }, + } +} + +async function listenLegacy(opts: ListenOptions): Promise { + const built = create(opts) + const server = await built.runtime.listen(opts) + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(server.port) return { hostname: opts.hostname, port: server.port, - url: next, - stop(close?: boolean) { - closing ??= (async () => { - if (mdns) MDNS.unpublish() - await server.stop(close) - })() - return closing + url: innerUrl, + stop: (close?: boolean) => server.stop(close), + } +} + +/** + * Run the effect-httpapi backend on a native Effect HTTP server. This + * lets HttpApi routes that call `request.upgrade` (PTY connect, the + * workspace-routing proxy WS bridge) work end-to-end; the legacy Hono + * adapter path can't surface `request.upgrade` because its fetch handler has + * no reference to the platform server instance for websocket upgrades. + */ +async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise { + log.info("server backend selected", { + ...ServerBackend.attributes(selection), + "opencode.server.runtime": HttpApiServer.name, + }) + + const buildLayer = (port: number) => + HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), { + middleware: disposeMiddleware, + disableLogger: true, + disableListenLog: true, + }).pipe( + Layer.provideMerge(WebSocketTracker.layer), + Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })), + // Install a fresh `ConfigProvider` per listener so `Config.string(...)` + // reads reflect the current `process.env`. Effect's default + // `ConfigProvider` snapshots `process.env` on first read and caches the + // result on a module-singleton Reference; without overriding it here, + // every later `Server.listen()` keeps observing that initial snapshot. + Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), + ) + + const start = async (port: number) => { + const scope = Scope.makeUnsafe() + try { + // Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by + // design, which leaks `R = any` through `HttpRouter.serve`. The actual + // requirements at this point are fully satisfied by `createRoutes` and the + // platform HTTP server layer; cast away the `any` to satisfy `runPromise`. + const layer = buildLayer(port) as Layer.Layer< + HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service, + unknown, + never + > + const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope)) + return { scope, ctx } + } catch (err) { + await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined) + throw err + } + } + + // Match the legacy adapter port-resolution behavior: explicit `0` prefers + // 4096 first, then any free port. + let resolved: Awaited> | undefined + if (opts.port === 0) { + resolved = await start(4096).catch(() => undefined) + if (!resolved) resolved = await start(0) + } else { + resolved = await start(opts.port) + } + if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`) + + const server = Context.get(resolved.ctx, HttpServer.HttpServer) + if (server.address._tag !== "TcpAddress") { + await Effect.runPromise(Scope.close(resolved.scope, Exit.void)) + throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`) + } + const port = server.address.port + + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(port) + let forceStopPromise: Promise | undefined + let stopPromise: Promise | undefined + const forceStop = () => { + forceStopPromise ??= Effect.runPromiseExit( + Effect.gen(function* () { + yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll + yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll + }), + ).then(() => undefined) + return forceStopPromise + } + + return { + hostname: opts.hostname, + port, + url: innerUrl, + stop: (close?: boolean) => { + const requested = close ? forceStop() : Promise.resolve() + // The first call starts scope shutdown. A later stop(true) cannot undo + // that, but it still runs forceStop() before awaiting the original close. + stopPromise ??= requested + .then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))) + .then(() => undefined) + return requested.then(() => stopPromise!) }, } } diff --git a/packages/opencode/src/server/shared/fence.ts b/packages/opencode/src/server/shared/fence.ts new file mode 100644 index 0000000000..f9359f2092 --- /dev/null +++ b/packages/opencode/src/server/shared/fence.ts @@ -0,0 +1,74 @@ +import { Database } from "@/storage/db" +import { inArray } from "drizzle-orm" +import { EventSequenceTable } from "@/sync/event.sql" +import { Workspace } from "@/control-plane/workspace" +import type { WorkspaceID } from "@/control-plane/schema" +import * as Log from "@opencode-ai/core/util/log" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" + +export const HEADER = "x-kilo-sync" +export type State = Record +const log = Log.create({ service: "fence" }) + +export function load(ids?: string[]) { + const rows = Database.use((db) => { + if (!ids?.length) { + return db.select().from(EventSequenceTable).all() + } + + return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() + }) + + return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State +} + +export function diff(prev: State, next: State) { + const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) + return Object.fromEntries( + [...ids] + .map((id) => [id, next[id] ?? -1] as const) + .filter(([id, seq]) => { + return (prev[id] ?? -1) !== seq + }), + ) as State +} + +export function parse(headers: Headers) { + const raw = headers.get(HEADER) + if (!raw) return + + let data + + try { + data = JSON.parse(raw) + } catch { + return + } + + if (!data || typeof data !== "object") return + + return Object.fromEntries( + Object.entries(data).filter(([id, seq]) => { + return typeof id === "string" && Number.isInteger(seq) + }), + ) as State +} + +export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + return Effect.gen(function* () { + log.info("waiting for state", { + workspaceID, + state, + }) + yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) + log.info("state fully synced", { + workspaceID, + state, + }) + }) +} + +export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) +} diff --git a/packages/opencode/src/server/shared/pty-ticket.ts b/packages/opencode/src/server/shared/pty-ticket.ts new file mode 100644 index 0000000000..62c38e686c --- /dev/null +++ b/packages/opencode/src/server/shared/pty-ticket.ts @@ -0,0 +1,15 @@ +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-kilo-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/ + +// Auth middleware skips Basic Auth when this matches; the PTY connect handler +// is then responsible for validating the ticket. +export function isPtyConnectPath(pathname: string) { + return PTY_CONNECT_PATH.test(pathname) +} + +export function hasPtyConnectTicketURL(url: URL) { + return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} diff --git a/packages/opencode/src/server/shared/public-ui.ts b/packages/opencode/src/server/shared/public-ui.ts new file mode 100644 index 0000000000..fece09592f --- /dev/null +++ b/packages/opencode/src/server/shared/public-ui.ts @@ -0,0 +1,12 @@ +// Static UI assets the browser fetches without app-managed credentials, e.g. +// the manifest link in . These bypass auth so the page can install/render +// the manifest icons even when a server password is configured. +export const PUBLIC_UI_PATHS = new Set([ + "/site.webmanifest", + "/web-app-manifest-192x192.png", + "/web-app-manifest-512x512.png", +]) + +export function isPublicUIPath(method: string, pathname: string) { + return method === "GET" && PUBLIC_UI_PATHS.has(pathname) +} diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts new file mode 100644 index 0000000000..40aaf04a96 --- /dev/null +++ b/packages/opencode/src/server/shared/tui-control.ts @@ -0,0 +1,28 @@ +import z from "zod" +import { AsyncQueue } from "@/util/queue" + +export const TuiRequest = z.object({ + path: z.string(), + body: z.any(), +}) + +export type TuiRequest = z.infer + +const request = new AsyncQueue() +const response = new AsyncQueue() + +export function nextTuiRequest() { + return request.next() +} + +export function submitTuiRequest(body: TuiRequest) { + request.push(body) +} + +export function submitTuiResponse(body: unknown) { + response.push(body) +} + +export function nextTuiResponse() { + return response.next() +} diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts new file mode 100644 index 0000000000..94246bcf5e --- /dev/null +++ b/packages/opencode/src/server/shared/ui.ts @@ -0,0 +1,105 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Stream } from "effect" +import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createHash } from "node:crypto" +import { ProxyUtil } from "../proxy-util" + +const embeddedUIPromise = Flag.KILO_DISABLE_EMBEDDED_WEB_UI + ? Promise.resolve(null) + : // @ts-expect-error - generated file at build time + import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) + +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" +export const UI_UPSTREAM = new URL("https://app.opencode.ai") + +export const csp = (hash = "") => + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` + +export function themePreloadHash(body: string) { + return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) +} + +function requestBody(request: HttpServerRequest.HttpServerRequest) { + if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + const len = request.headers["content-length"] + return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) +} + +function proxyResponseHeaders(headers: Record) { + const result = new Headers(headers) + // FetchHttpClient exposes decoded response bodies, so forwarding upstream + // transfer metadata makes browsers decode already-decoded assets again. + result.delete("content-encoding") + result.delete("content-length") + result.delete("transfer-encoding") + return result +} + +export function upstreamURL(path: string) { + return new URL(path, UI_UPSTREAM).toString() +} + +export function embeddedUI() { + if (Flag.KILO_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) + return embeddedUIPromise +} + +function notFound() { + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) +} + +function embeddedUIResponse(file: string, body: Uint8Array) { + const mime = AppFileSystem.mimeType(file) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(body, { headers }) +} + +export function serveEmbeddedUIEffect( + requestPath: string, + fs: AppFileSystem.Interface, + embeddedWebUI: Record, +) { + const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!file) return Effect.succeed(notFound()) + + return fs.readFile(file).pipe( + Effect.map((body) => embeddedUIResponse(file, body)), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())), + ) +} + +export function serveUIEffect( + request: HttpServerRequest.HttpServerRequest, + services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, +) { + return Effect.gen(function* () { + const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) + const path = new URL(request.url, "http://localhost").pathname + + if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI) + + const response = yield* services.client.execute( + HttpClientRequest.make(request.method)(upstreamURL(path), { + headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), + body: requestBody(request), + }), + ) + const headers = proxyResponseHeaders(response.headers) + + if (response.headers["content-type"]?.includes("text/html")) { + const body = yield* response.text + const match = themePreloadHash(body) + headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) + return HttpServerResponse.text(body, { status: response.status, headers }) + } + + headers.set("Content-Security-Policy", csp()) + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { + status: response.status, + headers, + }) + }) +} diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts new file mode 100644 index 0000000000..366c455dd6 --- /dev/null +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -0,0 +1,36 @@ +import { SessionID } from "@/session/schema" + +type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } + +const RULES: Array = [ + { path: "/experimental/workspace", action: "local" }, + { path: "/session/status", action: "forward" }, + { method: "GET", path: "/session", action: "local" }, +] + +export function isLocalWorkspaceRoute(method: string, path: string) { + for (const rule of RULES) { + if (rule.method && rule.method !== method) continue + const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") + if (match) return rule.action === "local" + } + return false +} + +export function getWorkspaceRouteSessionID(url: URL) { + if (url.pathname === "/session/status") return null + + const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] + if (!id) return null + + return SessionID.make(id) +} + +export function workspaceProxyURL(target: string | URL, requestURL: URL) { + const proxyURL = new URL(target) + proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` + proxyURL.search = requestURL.search + proxyURL.hash = requestURL.hash + proxyURL.searchParams.delete("workspace") + return proxyURL +} diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index fc4d073a19..b2b481e715 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -5,48 +5,13 @@ import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" -import { getBootstrapRunEffect, AppRuntime } from "@/effect/app-runtime" -import { Instance } from "@/project/instance" +import { AppRuntime } from "@/effect/app-runtime" +import { WithInstance } from "@/project/with-instance" import { Session } from "@/session/session" -import { SessionID } from "@/session/schema" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" - -type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } - -const RULES: Array = [ - { path: "/experimental/workspace", action: "local" }, - { path: "/session/status", action: "forward" }, - { method: "GET", path: "/session", action: "local" }, -] - -export function isLocalWorkspaceRoute(method: string, path: string) { - for (const rule of RULES) { - if (rule.method && rule.method !== method) continue - const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") - if (match) return rule.action === "local" - } - return false -} - -export function getWorkspaceRouteSessionID(url: URL) { - if (url.pathname === "/session/status") return null - - const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] - if (!id) return null - - return SessionID.make(id) -} - -export function workspaceProxyURL(target: string | URL, requestURL: URL) { - const proxyURL = new URL(target) - proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` - proxyURL.search = requestURL.search - proxyURL.hash = requestURL.hash - proxyURL.searchParams.delete("workspace") - return proxyURL -} +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing" async function getSessionWorkspace(url: URL) { const id = getWorkspaceRouteSessionID(url) @@ -94,13 +59,11 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const target = await adapter.target(workspace) if (target.type === "local") { - const init = await getBootstrapRunEffect() return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => - Instance.provide({ + WithInstance.provide({ directory: target.directory, - init, async fn() { return next() }, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index aaee2be2fe..067d43da2e 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,10 +14,13 @@ import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context, Schema } from "effect" +import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" const log = Log.create({ service: "session.compaction" }) @@ -556,7 +559,21 @@ export const layer: Layer.Layer< } if (processor.message.error) return "stop" - if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + if (result === "continue") { + const summary = summaryText( + (yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? { + info: msg, + parts: [], + }, + ) + EventV2.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + include: selected.tail_start_id, + }) + yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + } return result }) @@ -583,6 +600,11 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) + EventV2.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) }) return Service.of({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b475ec1c59..cf1a7e0ae9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -20,6 +20,9 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -221,6 +224,12 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -248,6 +257,13 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -260,6 +276,13 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -281,13 +304,34 @@ export const layer: Layer.Layer< case "tool-input-delta": return - case "tool-input-end": + case "tool-input-end": { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) return + } case "tool-call": { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -331,11 +375,48 @@ export const layer: Layer.Layer< } case "tool-result": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: value.output.metadata, + content: [ + { + type: "text", + text: value.output.output, + }, + ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* completeToolCall(value.toolCallId, value.output) return } case "tool-error": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Failed.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* failToolCall(value.toolCallId, value.error) return } @@ -345,6 +426,20 @@ export const layer: Layer.Layer< case "start-step": if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: ctx.model.id, + providerID: ctx.model.providerID, + variant: input.assistantMessage.variant, + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -355,18 +450,30 @@ export const layer: Layer.Layer< return case "finish-step": { + const completedSnapshot = yield* snapshot.track() const usage = Session.getUsage({ model: ctx.model, usage: value.usage, metadata: value.providerMetadata, }) + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ id: PartID.ascending(), reason: value.finishReason, - snapshot: yield* snapshot.track(), + snapshot: completedSnapshot, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "step-finish", @@ -404,6 +511,13 @@ export const layer: Layer.Layer< } case "text-start": + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -442,6 +556,14 @@ export const layer: Layer.Layer< }, { text: ctx.currentText.text }, )).text + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -528,6 +650,17 @@ export const layer: Layer.Layer< yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: error.name, + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, @@ -568,13 +701,24 @@ export const layer: Layer.Layer< Effect.retry( SessionRetry.policy({ parse, - set: (info) => - status.set(ctx.sessionID, { + set: (info) => { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + return status.set(ctx.sessionID, { type: "retry", attempt: info.attempt, message: info.message, next: info.next, - }), + }) + }, }), ), Effect.catch(halt), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts new file mode 100644 index 0000000000..88f73acf1a --- /dev/null +++ b/packages/opencode/src/session/projectors-next.ts @@ -0,0 +1,207 @@ +import { and, desc, eq } from "@/storage/db" +import type { Database } from "@/storage/db" +import { SessionMessage } from "@/v2/session-message" +import { SessionMessageUpdater } from "@/v2/session-message-updater" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" +import { SyncEvent } from "@/sync" +import { SessionMessageTable, SessionTable } from "./session.sql" +import type { SessionID } from "./schema" +import { Schema } from "effect" + +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) +type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]> + +function encodeDateTimes(value: unknown): unknown { + if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value) + if (Array.isArray(value)) return value.map(encodeDateTimes) + if (typeof value === "object" && value !== null) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)])) + } + return value +} + +function encodeMessageData(value: unknown): SessionMessageData { + return encodeDateTimes(value) as SessionMessageData +} + +function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter { + return { + getCurrentAssistant() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed) + }, + getCurrentCompaction() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Compaction => message.type === "compaction") + }, + getCurrentShell(callID) { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) + }, + updateAssistant(assistant) { + const { id, type, ...data } = assistant + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateCompaction(compaction) { + const { id, type, ...data } = compaction + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateShell(shell) { + const { id, type, ...data } = shell + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + appendMessage(message) { + const { id, type, ...data } = message + db.insert(SessionMessageTable) + .values([ + { + id, + session_id: sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data: encodeMessageData(data), + }, + ]) + .run() + }, + finish() {}, + } +} + +function update(db: Database.TxOrDb, event: SessionEvent.Event) { + SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event) +} + +export default [ + SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + agent: data.agent, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data }) + }), + SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + model: { + id: data.id, + providerID: data.providerID, + variant: data.variant, + }, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data }) + }), + SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data }) + }), + SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data }) + }), + SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data }) + }), + SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data }) + }), + SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data }) + }), + SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) + }), + SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data }) + }), + SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) + }), + SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data }) + }), + SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) + }), + SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data }) + }), + SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data }) + }), +] diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index a3832ebe65..9819ad810f 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" +import nextProjectors from "./projectors-next" const log = Log.create({ service: "session.projector" }) @@ -136,4 +137,6 @@ export default [ log.warn("ignored late part update", { partID: id, messageID, sessionID }) } }), + + ...nextProjectors, ] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7b67db5199..14d3eb5ee9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -41,6 +41,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" +import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -53,6 +54,13 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" +import * as DateTime from "effect/DateTime" +import { eq } from "@/storage/db" +import * as Database from "@/storage/db" +import { SessionTable } from "./session.sql" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -784,20 +792,28 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } yield* sessions.updateMessage(msg) + const callID = ulid() + const started = Date.now() const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + tool: ShellID.ToolID, callID: ulid(), state: { status: "running", - time: { start: Date.now() }, + time: { start: started }, input: { command: input.command }, }, } yield* sessions.updatePart(part) + EventV2.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -812,14 +828,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } + const completed = Date.now() + EventV2.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) if (!msg.time.completed) { - msg.time.completed = Date.now() + msg.time.completed = completed yield* sessions.updateMessage(msg) } if (part.state.status === "running") { part.state = { status: "completed", - time: { ...part.state.time, end: Date.now() }, + time: { ...part.state.time, end: completed }, input: part.state.input, title: "", metadata: { output, description: "" }, @@ -933,6 +956,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + if (current?.agent !== info.agent) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, + }) + } + if ( + current?.model?.providerID !== info.model.providerID || + current.model.id !== info.model.modelID || + current.model.variant !== info.model.variant + ) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + id: info.model.modelID, + providerID: info.model.providerID, + variant: info.model.variant, + }) + } + yield* Effect.addFinalizer(() => instruction.clear(info.id)) type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never @@ -1249,6 +1300,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(info) for (const part of parts) yield* sessions.updatePart(part) + const nextPrompt = parts.reduce( + (result, part) => { + if (part.type === "text") { + if (part.synthetic) result.synthetic.push(part.text) + else result.text.push(part.text) + } + if (part.type === "file") { + result.files.push( + new FileAttachment({ + uri: part.url, + mime: part.mime, + name: part.filename, + source: part.source + ? new Source({ + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + }) + : undefined, + }), + ) + } + if (part.type === "agent") { + result.agents.push( + new AgentAttachment({ + name: part.name, + source: part.source + ? new Source({ + start: part.source.start, + end: part.source.end, + text: part.source.value, + }) + : undefined, + }), + ) + } + return result + }, + { + text: [] as string[], + files: [] as FileAttachment[], + agents: [] as AgentAttachment[], + synthetic: [] as string[], + }, + ) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Prompted.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + }, + }) + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } return { info, parts } }, Effect.scoped) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 863fb21d65..421fa68694 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,7 +1,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { SessionEntry } from "../v2/session-entry" +import type { SessionMessage } from "../v2/session-message" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" @@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql" type PartData = Omit type InfoData = Omit +type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> export const SessionTable = sqliteTable( "session", @@ -34,6 +35,12 @@ export const SessionTable = sqliteTable( summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), + agent: text(), + model: text({ mode: "json" }).$type<{ + id: string + providerID: string + variant?: string + }>(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -96,22 +103,22 @@ export const TodoTable = sqliteTable( ], ) -export const SessionEntryTable = sqliteTable( - "session_entry", +export const SessionMessageTable = sqliteTable( + "session_message", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), + type: text().$type().notNull(), ...Timestamps, - data: text({ mode: "json" }).notNull().$type>(), + data: text({ mode: "json" }).notNull().$type(), }, (table) => [ - index("session_entry_session_idx").on(table.session_id), - index("session_entry_session_type_idx").on(table.session_id, table.type), - index("session_entry_time_created_idx").on(table.time_created), + index("session_message_session_idx").on(table.session_id), + index("session_message_session_type_idx").on(table.session_id, table.type), + index("session_message_time_created_idx").on(table.time_created), ], ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index cfd2068dba..b2e6402c49 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,6 +32,7 @@ import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" +import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" @@ -78,6 +79,14 @@ export function fromRow(row: SessionRow): Info { path: row.path ?? undefined, parentID: row.parent_id ?? undefined, title: row.title, + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } + : undefined, version: row.version, summary, share, @@ -102,6 +111,8 @@ export function toRow(info: Info) { directory: info.directory, path: info.path, title: info.title, + agent: info.agent, + model: info.model, version: info.version, share_url: info.share?.url, summary_additions: info.summary?.additions, @@ -160,6 +171,12 @@ const Revert = Schema.Struct({ diff: optionalOmitUndefined(Schema.String), }) +const Model = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + variant: optionalOmitUndefined(Schema.String), +}) + export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, @@ -171,6 +188,8 @@ export const Info = Schema.Struct({ summary: optionalOmitUndefined(Summary), share: optionalOmitUndefined(Share), title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(Model), version: Schema.String, time: Time, permission: optionalOmitUndefined(Permission.Ruleset), @@ -201,6 +220,8 @@ export const CreateInput = Schema.optional( Schema.Struct({ parentID: Schema.optional(SessionID), title: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Model), permission: Schema.optional(Permission.Ruleset), workspaceID: Schema.optional(WorkspaceID), }), @@ -272,6 +293,8 @@ const UpdatedInfo = Schema.Struct({ summary: Schema.optional(Schema.NullOr(Summary)), share: Schema.optional(UpdatedShare), title: Schema.optional(Schema.NullOr(Schema.String)), + agent: Schema.optional(Schema.NullOr(Schema.String)), + model: Schema.optional(Schema.NullOr(Model)), version: Schema.optional(Schema.NullOr(Schema.String)), time: Schema.optional(UpdatedTime), permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), @@ -404,6 +427,8 @@ export interface Interface { readonly create: (input?: { parentID?: SessionID title?: string + agent?: string + model?: Schema.Schema.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect @@ -464,6 +489,8 @@ export const layer: Layer.Layer parentID?: SessionID workspaceID?: WorkspaceID directory: string @@ -481,6 +508,8 @@ export const layer: Layer.Layer permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { @@ -601,6 +632,8 @@ export const layer: Layer.Layer { }) export function close() { + if (!Client.loaded()) return Client().$client.close() Client.reset() } diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 141955d2a6..142ce00d9f 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -46,7 +46,7 @@ export type Properties = EffectSchema.Schem export type SerializedEvent = Event & { type: string } -type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void +type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise type PublishContext = { instance?: InstanceContext @@ -255,7 +255,7 @@ export function define< export function project( def: Def, - func: (db: Database.TxOrDb, data: Event["data"]) => void, + func: (db: Database.TxOrDb, data: Event["data"], event: Event) => void, ): [Definition, ProjectorFunc] { return [def, func as ProjectorFunc] } @@ -277,7 +277,7 @@ function process( // idempotent: need to ignore any events already logged Database.transaction((tx) => { - projector(tx, event.data) + projector(tx, event.data, event) if (Flag.KILO_EXPERIMENTAL_WORKSPACES) { tx.insert(EventSequenceTable) @@ -308,7 +308,7 @@ function process( } const result = convertEvent(def.type, event.data) - const publish = (data: unknown) => ProjectBus.publish(def, data as Properties) + const publish = (data: unknown) => ProjectBus.publish(def, data as Properties, { id: event.id }) if (result instanceof Promise) { void result.then(publish) } else { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6d40722e0b..966fcaa046 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,7 +1,7 @@ import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" -import { BashTool } from "./bash" +import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -106,7 +106,7 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool - const bash = yield* BashTool + const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool @@ -195,7 +195,7 @@ export const layer: Layer.Layer< const tool = yield* Effect.all({ invalid: Tool.init(invalid), - bash: Tool.init(bash), + shell: Tool.init(shell), read: Tool.init(read), glob: Tool.init(globtool), grep: Tool.init(greptool), @@ -217,7 +217,7 @@ export const layer: Layer.Layer< builtin: [ tool.invalid, ...(questionEnabled ? [tool.question] : []), - tool.bash, + tool.shell, tool.read, tool.glob, tool.grep, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/shell.ts similarity index 81% rename from packages/opencode/src/tool/bash.ts rename to packages/opencode/src/tool/shell.ts index f508ee7a60..a35bd54422 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,12 +1,11 @@ -import { Schema } from "effect" -import { PositiveInt } from "@/util/schema" +import { Effect, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" import path from "path" -import DESCRIPTION from "./bash.txt" import * as Log from "@opencode-ai/core/util/log" import { containsPath, type InstanceContext } from "../project/instance-context" +import { InstanceState } from "@/effect/instance-state" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -14,20 +13,21 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" -import { Global } from "@opencode-ai/core/global" import { Shell } from "@/shell/shell" +import { ShellID } from "./shell/id" -import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" -import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import { InstanceState } from "@/effect/instance-state" +import { ShellPrompt, type Parameters } from "./shell/prompt" +import { BashArity } from "@/permission/arity" + +export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -const CWD = new Set(["cd", "push-location", "set-location"]) +const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, "rm", @@ -50,21 +50,23 @@ const FILES = new Set([ "new-item", "rename-item", ]) +const CMD_FILES = new Set([ + "copy", + "del", + "dir", + "erase", + "md", + "mkdir", + "move", + "rd", + "ren", + "rename", + "rmdir", + "type", +]) const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -export const Parameters = Schema.Struct({ - command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), - workdir: Schema.optional(Schema.String).annotate({ - description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - }), - description: Schema.String.annotate({ - description: - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - }), -}) - type Part = { type: string text: string @@ -81,7 +83,7 @@ type Chunk = { size: number } -export const log = Log.create({ service: "bash-tool" }) +export const log = Log.create({ service: "shell-tool" }) const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) @@ -187,11 +189,16 @@ function prefix(text: string) { return text.slice(0, match.index) } -function pathArgs(list: Part[], ps: boolean) { +function pathArgs(list: Part[], ps: boolean, cmd = false) { if (!ps) { return list .slice(1) - .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) + .filter( + (item) => + !item.text.startsWith("-") && + !(cmd && item.text.startsWith("/")) && + !(list[0]?.text === "chmod" && item.text.startsWith("+")), + ) .map((item) => item.text) } @@ -251,13 +258,13 @@ function tail(text: string, maxLines: number, maxBytes: number) { } } -const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { +const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) { const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) if (!tree) throw new Error("Failed to parse command") return tree }) -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) @@ -273,7 +280,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) if (scan.patterns.size === 0) return yield* ctx.ask({ - permission: "bash", + permission: ShellID.ToolID, patterns: Array.from(scan.patterns), always: Array.from(scan.always), metadata: {}, @@ -325,9 +332,8 @@ const parser = lazy(async () => { return { bash, ps } }) -// TODO: we may wanna rename this tool so it works better on other shells -export const BashTool = Tool.define( - "bash", +export const ShellTool = Tool.define( + ShellID.ToolID, Effect.gen(function* () { const config = yield* Config.Service const spawner = yield* ChildProcessSpawner @@ -335,7 +341,7 @@ export const BashTool = Tool.define( const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service - const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { + const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -344,7 +350,7 @@ export const BashTool = Tool.define( return AppFileSystem.normalizePath(file) }) - const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { + const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { if (process.platform === "win32") { if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { const file = yield* cygpath(shell, text) @@ -355,7 +361,7 @@ export const BashTool = Tool.define( return path.resolve(root, text) }) - const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { + const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) const file = text && prefix(text) if (!file || dynamic(file, ps)) return @@ -364,7 +370,7 @@ export const BashTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* ( + const collect = Effect.fn("ShellTool.collect")(function* ( root: Node, cwd: string, ps: boolean, @@ -376,14 +382,15 @@ export const BashTool = Tool.define( patterns: new Set(), always: new Set(), } + const shellKind = ShellID.toKind(Shell.name(shell)) for (const node of commands(root)) { const command = parts(node) const tokens = command.map((item) => item.text) - const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0] - if (cmd && FILES.has(cmd)) { - for (const arg of pathArgs(command, ps)) { + if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) { + for (const arg of pathArgs(command, ps, shellKind === "cmd")) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) if (!resolved || containsPath(resolved, instance)) continue @@ -401,7 +408,7 @@ export const BashTool = Tool.define( return scan }) - const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { const extra = yield* plugin.trigger( "shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -413,7 +420,7 @@ export const BashTool = Tool.define( } }) - const run = Effect.fn("BashTool.run")(function* ( + const run = Effect.fn("ShellTool.run")(function* ( input: { shell: string command: string @@ -527,7 +534,7 @@ export const BashTool = Tool.define( const meta: string[] = [] if (expired) { meta.push( - `bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, + `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, ) } if (aborted) meta.push("User aborted the command") @@ -546,7 +553,7 @@ export const BashTool = Tool.define( } if (meta.length > 0) { - output += "\n\n\n" + meta.join("\n") + "\n" + output += "\n\n\n" + meta.join("\n") + "\n" } if (sink) { const stream = sink @@ -577,25 +584,14 @@ export const BashTool = Tool.define( const cfg = yield* config.get() const shell = Shell.acceptable(cfg.shell) const name = Shell.name(shell) - const chain = - name === "powershell" - ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." - log.info("bash tool using shell", { shell }) - const limits = yield* trunc.limits() - const instance = yield* InstanceState.context + const prompt = ShellPrompt.render(name, process.platform, limits) + log.info("shell tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", instance.directory) - .replaceAll("${tmp}", Global.Path.tmp) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(limits.maxLines)) - .replaceAll("${maxBytes}", String(limits.maxBytes)), - parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + description: prompt.description, + parameters: prompt.parameters, + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const executeInstance = yield* InstanceState.context const cwd = params.workdir diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts new file mode 100644 index 0000000000..061253f8fb --- /dev/null +++ b/packages/opencode/src/tool/shell/id.ts @@ -0,0 +1,19 @@ +const kinds = ["bash", "pwsh", "powershell", "cmd"] as const +export type Kind = (typeof kinds)[number] + +const shellKinds = new Set(kinds) + +function isKind(value: string): value is Kind { + return shellKinds.has(value) +} + +export function toKind(value: string): Kind { + return isKind(value) ? value : "bash" +} + +// Keep the exposed tool ID and permission key as "bash" for compatibility with +// existing plugins, users, and saved permissions. Rename with opencode 2.0. +export const ToolID = "bash" +export type ToolID = typeof ToolID + +export * as ShellID from "./id" diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts new file mode 100644 index 0000000000..45c637863a --- /dev/null +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -0,0 +1,295 @@ +import { Schema } from "effect" +import DESCRIPTION from "./shell.txt" +import { PositiveInt } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { ShellID } from "./id" + +const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) + +const descriptions = { + bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', +} + +export type Limits = { + maxLines: number + maxBytes: number +} + +export function parameterSchema(description: string) { + return Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ description }), + }) +} + +export const Parameters = parameterSchema(descriptions.bash) +export type Parameters = Schema.Schema.Type + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + } + if (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before bash for git operations, or git add before git commit), run these operations sequentially instead." + } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } + return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." +} + +function bashCommandSection(chain: string, limits: Limits) { + return `Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo\\" dir "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + +function profile(name: string, platform: NodeJS.Platform, limits: Limits) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: descriptions.cmd, + } + } + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: descriptions.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.", + commandSection: bashCommandSection(chain, limits), + gitCommands: "bash commands", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: descriptions.bash, + } +} + +export function render(name: string, platform: NodeJS.Platform, limits: Limits) { + const selected = profile(name, platform, limits) + return { + description: renderPrompt(DESCRIPTION, { + intro: selected.intro, + os: platform, + shell: name, + tmp: Global.Path.tmp, + workdirSection: selected.workdirSection, + commandSection: selected.commandSection, + gitCommands: selected.gitCommands, + toolName: ShellID.ToolID, + gitCommandRestriction: selected.gitCommandRestriction, + createPrInstruction: selected.createPrInstruction, + createPrExample: selected.createPrExample, + }), + parameters: parameterSchema(selected.parameterDescription), + } +} + +export * as ShellPrompt from "./prompt" diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/shell/shell.txt similarity index 59% rename from packages/opencode/src/tool/bash.txt rename to packages/opencode/src/tool/shell/shell.txt index a131ed7e63..5cba07805c 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,54 +1,14 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +${intro} Be aware: OS: ${os}, Shell: ${shell} -All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +${workdirSection} Use `${tmp}` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - +${commandSection} # Committing changes with git @@ -67,7 +27,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel, each using the ${toolName} tool: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -84,18 +44,18 @@ Git Safety Protocol: 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction} - NEVER use the TodoWrite or Task tools - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command via the ${toolName} tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote @@ -104,11 +64,9 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Create new branch if needed - Push to remote with -u flag if needed - - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. + - ${createPrInstruction} -gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points> +${createPrExample} Important: diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76eb..1c88712d7d 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -90,7 +90,7 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` // on the inner Zod rather than a transform wrapper — so optional ASTs whose // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" + const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index fbda2dc50e..dabc6dfe18 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -7,7 +7,19 @@ export function errorFormat(error: unknown): string { if (typeof error === "object" && error !== null) { try { - return JSON.stringify(error, null, 2) + const json = JSON.stringify(error, null, 2) + // Plain objects whose own properties are all non-enumerable (or empty) + // serialize to "{}", which prints as a useless bare `{}` on stderr. + // Fall back to a custom toString first, then to ctor name + own prop names. + if (json === "{}") { + const str = String(error) + if (str && str !== "[object Object]") return str + const ctor = error.constructor?.name + const prefix = ctor && ctor !== "Object" ? ctor : "Error" + const names = Object.getOwnPropertyNames(error) + return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }` + } + return json } catch { return "Unexpected error (unserializable)" } @@ -34,7 +46,7 @@ export function errorMessage(error: unknown): string { if (text && text !== "[object Object]") return text const formatted = errorFormat(error) - if (formatted && formatted !== "{}") return formatted + if (formatted) return formatted return "unknown error" } @@ -45,7 +57,7 @@ export function errorData(error: unknown) { message: errorMessage(error), stack: error.stack, cause: error.cause === undefined ? undefined : errorFormat(error.cause), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -53,7 +65,7 @@ export function errorData(error: unknown) { return { type: typeof error, message: errorMessage(error), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -71,12 +83,6 @@ export function errorData(error: unknown) { if (typeof data.message !== "string") data.message = errorMessage(error) if (typeof data.type !== "string") data.type = error.constructor?.name - data.formatted = errorFormatted(error) + data.formatted = errorFormat(error) return data } - -function errorFormatted(error: unknown) { - const formatted = errorFormat(error) - if (formatted !== "{}") return formatted - return String(error) -} diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index 86967e11a0..d9abf18a52 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -14,5 +14,7 @@ export function lazy(fn: () => T) { value = undefined } + result.loaded = () => loaded + return result } diff --git a/packages/opencode/src/util/timeout.ts b/packages/opencode/src/util/timeout.ts index 31ac481468..22f2648c92 100644 --- a/packages/opencode/src/util/timeout.ts +++ b/packages/opencode/src/util/timeout.ts @@ -1,4 +1,4 @@ -export function withTimeout(promise: Promise, ms: number): Promise { +export function withTimeout(promise: Promise, ms: number, label?: string): Promise { let timeout: NodeJS.Timeout return Promise.race([ promise.finally(() => { @@ -6,7 +6,7 @@ export function withTimeout(promise: Promise, ms: number): Promise { }), new Promise((_, reject) => { timeout = setTimeout(() => { - reject(new Error(`Operation timed out after ${ms}ms`)) + reject(new Error(label ?? `Operation timed out after ${ms}ms`)) }, ms) }), ]) diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts new file mode 100644 index 0000000000..c7c13890f2 --- /dev/null +++ b/packages/opencode/src/v2/event.ts @@ -0,0 +1,53 @@ +import { Identifier } from "@/id/id" +import { SyncEvent } from "@/sync" +import { withStatics } from "@/util/schema" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Schema from "effect/Schema" + +export const ID = Schema.String.pipe( + Schema.brand("Event.ID"), + withStatics((s) => ({ + create: () => s.make(Identifier.create("evt", "ascending")), + })), +) +export type ID = Schema.Schema.Type + +export function define(input: { + type: Type + schema: Fields + aggregate: string + version?: number +}) { + const Payload = Schema.Struct({ + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + type: Schema.Literal(input.type), + data: Schema.Struct(input.schema), + }).annotate({ + identifier: input.type, + }) + + const Sync = SyncEvent.define({ + type: input.type, + version: input.version ?? 1, + aggregate: input.aggregate, + schema: Payload.fields.data, + }) + + return Object.assign(Payload, { + Sync, + version: input.version, + aggregate: input.aggregate, + }) +} + +export function run( + def: Def, + data: SyncEvent.Event["data"], + options?: { publish?: boolean }, +) { + if (!Flag.KILO_EXPERIMENTAL_EVENT_SYSTEM) return + SyncEvent.run(def, data, options) +} + +export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/schema.ts b/packages/opencode/src/v2/schema.ts new file mode 100644 index 0000000000..44587b838a --- /dev/null +++ b/packages/opencode/src/v2/schema.ts @@ -0,0 +1,10 @@ +import { DateTime, Schema, SchemaGetter } from "effect" + +export const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) + +export * as V2Schema from "./schema" diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts deleted file mode 100644 index 3fe4266c04..0000000000 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { produce, type WritableDraft } from "immer" -import { SessionEvent } from "./session-event" -import { SessionEntry } from "./session-entry" - -export type MemoryState = { - entries: SessionEntry.Entry[] - pending: SessionEntry.Entry[] -} - -export interface Adapter { - readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined - readonly updateAssistant: (assistant: SessionEntry.Assistant) => void - readonly appendEntry: (entry: SessionEntry.Entry) => void - readonly appendPending: (entry: SessionEntry.Entry) => void - readonly finish: () => Result -} - -export function memory(state: MemoryState): Adapter { - const activeAssistantIndex = () => - state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - return { - getCurrentAssistant() { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = state.entries[index] - return assistant?.type === "assistant" ? assistant : undefined - }, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = state.entries[index] - if (current?.type !== "assistant") return - state.entries[index] = assistant - }, - appendEntry(entry) { - state.entries.push(entry) - }, - appendPending(entry) { - state.pending.push(entry) - }, - finish() { - return state - }, - } -} - -export function stepWith(adapter: Adapter, event: SessionEvent.Event): Result { - const currentAssistant = adapter.getCurrentAssistant() - type DraftAssistant = WritableDraft - type DraftTool = WritableDraft - type DraftText = WritableDraft - type DraftReasoning = WritableDraft - - const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => - assistant?.content.findLast( - (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), - ) - - const latestText = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftText => item.type === "text") - - const latestReasoning = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") - - SessionEvent.Event.match(event, { - prompt: (event) => { - const entry = SessionEntry.User.fromEvent(event) - if (currentAssistant) { - adapter.appendPending(entry) - return - } - adapter.appendEntry(entry) - }, - synthetic: (event) => { - adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) - }, - "step.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - }), - ) - } - adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) - }, - "step.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - draft.cost = event.cost - draft.tokens = event.tokens - }), - ) - } - }, - "text.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "text", - text: "", - }) - }), - ) - } - }, - "text.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "text.ended": () => {}, - "tool.input.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "tool", - callID: event.callID, - name: event.name, - time: { - created: event.timestamp, - }, - state: { - status: "pending", - input: "", - }, - }) - }), - ) - } - }, - "tool.input.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) - if (match && match.state.status === "pending") match.state.input += event.delta - }), - ) - } - }, - "tool.input.ended": () => {}, - "tool.called": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match) { - match.time.ran = event.timestamp - match.state = { - status: "running", - input: event.input, - } - } - }), - ) - } - }, - "tool.success": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "completed", - input: match.state.input, - output: event.output ?? "", - title: event.title, - metadata: event.metadata ?? {}, - attachments: [...(event.attachments ?? [])], - } - } - }), - ) - } - }, - "tool.error": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "error", - error: event.error, - input: match.state.input, - metadata: event.metadata ?? {}, - } - } - }), - ) - } - }, - "reasoning.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "reasoning", - text: "", - }) - }), - ) - } - }, - "reasoning.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "reasoning.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text = event.text - }), - ) - } - }, - retried: (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)] - }), - ) - } - }, - compacted: (event) => { - adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) - }, - }) - - return adapter.finish() -} - -export function step(old: MemoryState, event: SessionEvent.Event): MemoryState { - return produce(old, (draft) => { - stepWith(memory(draft as MemoryState), event) - }) -} - -export * as SessionEntryStepper from "./session-entry-stepper" diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts deleted file mode 100644 index 66576a688e..0000000000 --- a/packages/opencode/src/v2/session-entry.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Schema } from "effect" -import { NonNegativeInt } from "@/util/schema" -import { SessionEvent } from "./session-event" - -export const ID = SessionEvent.ID -export type ID = Schema.Schema.Type - -const Base = { - id: SessionEvent.ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -} - -export class User extends Schema.Class("Session.Entry.User")({ - ...Base, - text: SessionEvent.Prompt.fields.text, - files: SessionEvent.Prompt.fields.files, - agents: SessionEvent.Prompt.fields.agents, - type: Schema.Literal("user"), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Prompt) { - return new User({ - id: event.id, - type: "user", - metadata: event.metadata, - text: event.text, - files: event.files, - agents: event.agents, - time: { created: event.timestamp }, - }) - } -} - -export class Synthetic extends Schema.Class("Session.Entry.Synthetic")({ - ...SessionEvent.Synthetic.fields, - ...Base, - type: Schema.Literal("synthetic"), -}) { - static fromEvent(event: SessionEvent.Synthetic) { - return new Synthetic({ - ...event, - time: { created: event.timestamp }, - }) - } -} - -export class ToolStatePending extends Schema.Class("Session.Entry.ToolState.Pending")({ - status: Schema.Literal("pending"), - input: Schema.String, -}) {} - -export class ToolStateRunning extends Schema.Class("Session.Entry.ToolState.Running")({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Unknown), - title: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export class ToolStateCompleted extends Schema.Class("Session.Entry.ToolState.Completed")({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Unknown), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown), - attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), -}) {} - -export class ToolStateError extends Schema.Class("Session.Entry.ToolState.Error")({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Unknown), - error: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( - Schema.toTaggedUnion("status"), -) -export type ToolState = Schema.Schema.Type - -export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ - type: Schema.Literal("tool"), - callID: Schema.String, - name: Schema.String, - state: ToolState, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - ran: Schema.DateTimeUtc.pipe(Schema.optional), - completed: Schema.DateTimeUtc.pipe(Schema.optional), - pruned: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) {} - -export class AssistantText extends Schema.Class("Session.Entry.Assistant.Text")({ - type: Schema.Literal("text"), - text: Schema.String, -}) {} - -export class AssistantReasoning extends Schema.Class("Session.Entry.Assistant.Reasoning")({ - type: Schema.Literal("reasoning"), - text: Schema.String, -}) {} - -export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ - attempt: NonNegativeInt, - error: SessionEvent.RetryError, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Retried) { - return new AssistantRetry({ - attempt: event.attempt, - error: event.error, - time: { - created: event.timestamp, - }, - }) - } -} - -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( - Schema.toTaggedUnion("type"), -) -export type AssistantContent = Schema.Schema.Type - -export class Assistant extends Schema.Class("Session.Entry.Assistant")({ - ...Base, - type: Schema.Literal("assistant"), - content: AssistantContent.pipe(Schema.Array), - retries: AssistantRetry.pipe(Schema.Array, Schema.optional), - cost: Schema.Finite.pipe(Schema.optional), - tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, - cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, - }), - }).pipe(Schema.optional), - error: Schema.String.pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - completed: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) { - static fromEvent(event: SessionEvent.Step.Started) { - return new Assistant({ - id: event.id, - type: "assistant", - time: { - created: event.timestamp, - }, - content: [], - retries: [], - }) - } -} - -export class Compaction extends Schema.Class("Session.Entry.Compaction")({ - ...SessionEvent.Compacted.fields, - type: Schema.Literal("compaction"), - ...Base, -}) { - static fromEvent(event: SessionEvent.Compacted) { - return new Compaction({ - ...event, - type: "compaction", - time: { created: event.timestamp }, - }) - } -} - -export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) - -export type Entry = Schema.Schema.Type - -export type Type = Entry["type"] - -/* -export interface Interface { - readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry - readonly fromSession: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/SessionEntry") {} - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const decodeEntry = Schema.decodeUnknownSync(Entry) - - const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type }) - - const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) { - return Database.use((db) => - db - .select() - .from(SessionEntryTable) - .where(eq(SessionEntryTable.session_id, sessionID)) - .orderBy(SessionEntryTable.id) - .all() - .map((row) => decode(row)), - ) - }) - - return Service.of({ - decode, - fromSession, - }) - }), -) -*/ - -export * as SessionEntry from "./session-entry" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index aaf71c8dcc..47938dcbed 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,128 +1,124 @@ -import { Identifier } from "@/id/id" -import { NonNegativeInt, withStatics } from "@/util/schema" -import * as DateTime from "effect/DateTime" +import { SessionID } from "@/session/schema" +import { NonNegativeInt } from "@/util/schema" +import { EventV2 } from "./event" +import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" +export { FileAttachment } +import { ToolOutput } from "./tool-output" +import { ModelID, ProviderID } from "@/provider/schema" +import { V2Schema } from "./schema" -export namespace SessionEvent { - export const ID = Schema.String.pipe( - Schema.brand("Session.Event.ID"), - withStatics((s) => ({ - create: () => s.make(Identifier.create("evt", "ascending")), - })), - ) - export type ID = Schema.Schema.Type - type Stamp = Schema.Schema.Type - type BaseInput = { - id?: ID - metadata?: Record - timestamp?: Stamp - } +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export type Source = Schema.Schema.Type - const Base = { - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - timestamp: Schema.DateTimeUtc, - } +const Base = { + timestamp: V2Schema.DateTimeUtcFromMillis, + sessionID: SessionID, +} - export class Source extends Schema.Class("Session.Event.Source")({ - start: NonNegativeInt, - end: NonNegativeInt, - text: Schema.String, - }) {} +const Error = Schema.Struct({ + type: Schema.String, + message: Schema.String, +}) - export class FileAttachment extends Schema.Class("Session.Event.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), - }) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } - } - - export class AgentAttachment extends Schema.Class("Session.Event.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), - }) {} - - export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ - message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - }) {} - - export class Prompt extends Schema.Class("Session.Event.Prompt")({ +export const AgentSwitched = EventV2.define({ + type: "session.next.agent.switched", + aggregate: "sessionID", + version: 1, + schema: { ...Base, - type: Schema.Literal("prompt"), - text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), - }) { - static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) { - return new Prompt({ - id: input.id ?? ID.create(), - type: "prompt", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - files: input.files, - agents: input.agents, - }) - } - } + agent: Schema.String, + }, +}) +export type AgentSwitched = Schema.Schema.Type - export class Synthetic extends Schema.Class("Session.Event.Synthetic")({ +export const ModelSwitched = EventV2.define({ + type: "session.next.model.switched", + aggregate: "sessionID", + version: 1, + schema: { ...Base, - type: Schema.Literal("synthetic"), - text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Synthetic({ - id: input.id ?? ID.create(), - type: "synthetic", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } + id: ModelID, + providerID: ProviderID, + variant: Schema.String.pipe(Schema.optional), + }, +}) +export type ModelSwitched = Schema.Schema.Type - export namespace Step { - export class Started extends Schema.Class("Session.Event.Step.Started")({ +export const Prompted = EventV2.define({ + type: "session.next.prompted", + aggregate: "sessionID", + version: 1, + schema: { + ...Base, + prompt: Prompt, + }, +}) +export type Prompted = Schema.Schema.Type + +export const Synthetic = EventV2.define({ + type: "session.next.synthetic", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + }, +}) +export type Synthetic = Schema.Schema.Type + +export namespace Shell { + export const Started = EventV2.define({ + type: "session.next.shell.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.started"), + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.shell.ended", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = Schema.Schema.Type +} + +export namespace Step { + export const Started = EventV2.define({ + type: "session.next.step.started", + aggregate: "sessionID", + schema: { + ...Base, + agent: Schema.String, model: Schema.Struct({ id: Schema.String, providerID: Schema.String, variant: Schema.String.pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) { - return new Started({ - id: input.id ?? ID.create(), - type: "step.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - model: input.model, - }) - } - } + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Started = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Step.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.step.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.ended"), - reason: Schema.String, + finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ input: NonNegativeInt, @@ -133,177 +129,128 @@ export namespace SessionEvent { write: NonNegativeInt, }), }), - }) { - static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "step.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - reason: input.reason, - cost: input.cost, - tokens: input.tokens, - }) - } - } - } + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type - export namespace Text { - export class Started extends Schema.Class("Session.Event.Text.Started")({ + export const Failed = EventV2.define({ + type: "session.next.step.failed", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "text.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } + error: Error, + }, + }) + export type Failed = Schema.Schema.Type +} - export class Delta extends Schema.Class("Session.Event.Text.Delta")({ +export namespace Text { + export const Started = EventV2.define({ + type: "session.next.text.started", + aggregate: "sessionID", + schema: { + ...Base, + }, + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.text.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "text.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Text.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.text.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "text.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Reasoning { - export class Started extends Schema.Class("Session.Event.Reasoning.Started")({ +export namespace Reasoning { + export const Started = EventV2.define({ + type: "session.next.reasoning.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "reasoning.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } + reasoningID: Schema.String, + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Reasoning.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.reasoning.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.delta"), + reasoningID: Schema.String, delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "reasoning.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Reasoning.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.reasoning.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.ended"), + reasoningID: Schema.String, text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "reasoning.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Tool { - export namespace Input { - export class Started extends Schema.Class("Session.Event.Tool.Input.Started")({ +export namespace Tool { + export namespace Input { + export const Started = EventV2.define({ + type: "session.next.tool.input.started", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, name: Schema.String, - type: Schema.Literal("tool.input.started"), - }) { - static create(input: BaseInput & { callID: string; name: string }) { - return new Started({ - id: input.id ?? ID.create(), - type: "tool.input.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - name: input.name, - }) - } - } + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Tool.Input.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.tool.input.delta", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { callID: string; delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "tool.input.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Tool.Input.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.tool.input.ended", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { callID: string; text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "tool.input.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type + } - export class Called extends Schema.Class("Session.Event.Tool.Called")({ + export const Called = EventV2.define({ + type: "session.next.tool.called", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.called"), callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -311,148 +258,153 @@ export namespace SessionEvent { executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - tool: string - input: Record - provider: Called["provider"] - }, - ) { - return new Called({ - id: input.id ?? ID.create(), - type: "tool.called", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - tool: input.tool, - input: input.input, - provider: input.provider, - }) - } - } + }, + }) + export type Called = Schema.Schema.Type - export class Success extends Schema.Class("Session.Event.Tool.Success")({ + export const Progress = EventV2.define({ + type: "session.next.tool.progress", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.success"), callID: Schema.String, - title: Schema.String, - output: Schema.String.pipe(Schema.optional), - attachments: Schema.Array(FileAttachment).pipe(Schema.optional), + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), + }, + }) + export type Progress = Schema.Schema.Type + + export const Success = EventV2.define({ + type: "session.next.tool.success", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - title: string - output?: string - attachments?: FileAttachment[] - provider: Success["provider"] - }, - ) { - return new Success({ - id: input.id ?? ID.create(), - type: "tool.success", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - title: input.title, - output: input.output, - attachments: input.attachments, - provider: input.provider, - }) - } - } + }, + }) + export type Success = Schema.Schema.Type - export class Error extends Schema.Class("Session.Event.Tool.Error")({ + export const Failed = EventV2.define({ + type: "session.next.tool.failed", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.error"), callID: Schema.String, - error: Schema.String, + error: Error, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) { - return new Error({ - id: input.id ?? ID.create(), - type: "tool.error", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - error: input.error, - provider: input.provider, - }) - } - } - } + }, + }) + export type Failed = Schema.Schema.Type +} - export class Retried extends Schema.Class("Session.Event.Retried")({ +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: NonNegativeInt.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export type RetryError = Schema.Schema.Type + +export const Retried = EventV2.define({ + type: "session.next.retried", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("retried"), attempt: NonNegativeInt, error: RetryError, - }) { - static create(input: BaseInput & { attempt: number; error: RetryError }) { - return new Retried({ - id: input.id ?? ID.create(), - type: "retried", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - attempt: input.attempt, - error: input.error, - }) - } - } + }, +}) +export type Retried = Schema.Schema.Type - export class Compacted extends Schema.Class("Session.Event.Compated")({ - ...Base, - type: Schema.Literal("compacted"), - auto: Schema.Boolean, - overflow: Schema.Boolean.pipe(Schema.optional), - }) { - static create(input: BaseInput & { auto: boolean; overflow?: boolean }) { - return new Compacted({ - id: input.id ?? ID.create(), - type: "compacted", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - auto: input.auto, - overflow: input.overflow, - }) - } - } - - export const Event = Schema.Union( - [ - Prompt, - Synthetic, - Step.Started, - Step.Ended, - Text.Started, - Text.Delta, - Text.Ended, - Tool.Input.Started, - Tool.Input.Delta, - Tool.Input.Ended, - Tool.Called, - Tool.Success, - Tool.Error, - Reasoning.Started, - Reasoning.Delta, - Reasoning.Ended, - Retried, - Compacted, - ], - { - mode: "oneOf", +export namespace Compaction { + export const Started = EventV2.define({ + type: "session.next.compaction.started", + aggregate: "sessionID", + schema: { + ...Base, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, - ).pipe(Schema.toTaggedUnion("type")) - export type Event = Schema.Schema.Type - export type Type = Event["type"] + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.compaction.delta", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + }, + }) + + export const Ended = EventV2.define({ + type: "session.next.compaction.ended", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + include: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type } + +export const All = Schema.Union( + [ + AgentSwitched, + ModelSwitched, + Prompted, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Delta, + Text.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, + ], + { + mode: "oneOf", + }, +).pipe(Schema.toTaggedUnion("type")) + +// user +// assistant +// assistant +// assistant +// user +// compaction marker +// -> text +// assistant + +export type Event = Schema.Schema.Type +export type Type = Event["type"] + +export * as SessionEvent from "./session-event" diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts new file mode 100644 index 0000000000..d5d5aac7b7 --- /dev/null +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -0,0 +1,420 @@ +import { produce, type WritableDraft } from "immer" +import { SessionEvent } from "./session-event" +import { SessionMessage } from "./session-message" + +export type MemoryState = { + messages: SessionMessage.Message[] +} + +export interface Adapter { + readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined + readonly getCurrentCompaction: () => SessionMessage.Compaction | undefined + readonly getCurrentShell: (callID: string) => SessionMessage.Shell | undefined + readonly updateAssistant: (assistant: SessionMessage.Assistant) => void + readonly updateCompaction: (compaction: SessionMessage.Compaction) => void + readonly updateShell: (shell: SessionMessage.Shell) => void + readonly appendMessage: (message: SessionMessage.Message) => void + readonly finish: () => Result +} + +export function memory(state: MemoryState): Adapter { + const activeAssistantIndex = () => + state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction") + const activeShellIndex = (callID: string) => + state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + + return { + getCurrentAssistant() { + const index = activeAssistantIndex() + if (index < 0) return + const assistant = state.messages[index] + return assistant?.type === "assistant" ? assistant : undefined + }, + getCurrentCompaction() { + const index = activeCompactionIndex() + if (index < 0) return + const compaction = state.messages[index] + return compaction?.type === "compaction" ? compaction : undefined + }, + getCurrentShell(callID) { + const index = activeShellIndex(callID) + if (index < 0) return + const shell = state.messages[index] + return shell?.type === "shell" ? shell : undefined + }, + updateAssistant(assistant) { + const index = activeAssistantIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "assistant") return + state.messages[index] = assistant + }, + updateCompaction(compaction) { + const index = activeCompactionIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "compaction") return + state.messages[index] = compaction + }, + updateShell(shell) { + const index = activeShellIndex(shell.callID) + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "shell") return + state.messages[index] = shell + }, + appendMessage(message) { + state.messages.push(message) + }, + finish() { + return state + }, + } +} + +export function update(adapter: Adapter, event: SessionEvent.Event): Result { + const currentAssistant = adapter.getCurrentAssistant() + type DraftAssistant = WritableDraft + type DraftTool = WritableDraft + type DraftText = WritableDraft + type DraftReasoning = WritableDraft + + const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => + assistant?.content.findLast( + (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) + + const latestText = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftText => item.type === "text") + + const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => + assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) + + SessionEvent.All.match(event, { + "session.next.agent.switched": (event) => { + adapter.appendMessage( + new SessionMessage.AgentSwitched({ + id: event.id, + type: "agent-switched", + metadata: event.metadata, + agent: event.data.agent, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.model.switched": (event) => { + adapter.appendMessage( + new SessionMessage.ModelSwitched({ + id: event.id, + type: "model-switched", + metadata: event.metadata, + model: { + id: event.data.id, + providerID: event.data.providerID, + variant: event.data.variant, + }, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.prompted": (event) => { + adapter.appendMessage( + new SessionMessage.User({ + id: event.id, + type: "user", + metadata: event.metadata, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.synthetic": (event) => { + adapter.appendMessage( + new SessionMessage.Synthetic({ + sessionID: event.data.sessionID, + text: event.data.text, + id: event.id, + type: "synthetic", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.started": (event) => { + adapter.appendMessage( + new SessionMessage.Shell({ + id: event.id, + type: "shell", + metadata: event.metadata, + callID: event.data.callID, + command: event.data.command, + output: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.ended": (event) => { + const currentShell = adapter.getCurrentShell(event.data.callID) + if (currentShell) { + adapter.updateShell( + produce(currentShell, (draft) => { + draft.output = event.data.output + draft.time.completed = event.data.timestamp + }), + ) + } + }, + "session.next.step.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + }), + ) + } + adapter.appendMessage( + new SessionMessage.Assistant({ + id: event.id, + type: "assistant", + agent: event.data.agent, + model: event.data.model, + time: { created: event.data.timestamp }, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + }), + ) + }, + "session.next.step.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = event.data.finish + draft.cost = event.data.cost + draft.tokens = event.data.tokens + if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot } + }), + ) + } + }, + "session.next.step.failed": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = "error" + draft.error = event.data.error + }), + ) + } + }, + "session.next.text.started": () => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "text", + text: "", + }) + }), + ) + } + }, + "session.next.text.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.text.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.tool.input.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "tool", + id: event.data.callID, + name: event.data.name, + time: { + created: event.data.timestamp, + }, + state: { + status: "pending", + input: "", + }, + }) + }), + ) + } + }, + "session.next.tool.input.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) + if (match && match.state.status === "pending") match.state.input += event.data.delta + }), + ) + } + }, + "session.next.tool.input.ended": () => {}, + "session.next.tool.called": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match) { + match.provider = event.data.provider + match.time.ran = event.data.timestamp + match.state = { + status: "running", + input: event.data.input, + structured: {}, + content: [], + } + } + }), + ) + } + }, + "session.next.tool.progress": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.state.structured = event.data.structured + match.state.content = [...event.data.content] + } + }), + ) + } + }, + "session.next.tool.success": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "completed", + input: match.state.input, + structured: event.data.structured, + content: [...event.data.content], + } + } + }), + ) + } + }, + "session.next.tool.failed": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "error", + error: event.data.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + } + }), + ) + } + }, + "session.next.reasoning.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + }) + }), + ) + } + }, + "session.next.reasoning.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.reasoning.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.retried": () => {}, + "session.next.compaction.started": (event) => { + adapter.appendMessage( + new SessionMessage.Compaction({ + id: event.id, + type: "compaction", + metadata: event.metadata, + reason: event.data.reason, + summary: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.compaction.delta": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary += event.data.text + }), + ) + } + }, + "session.next.compaction.ended": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary = event.data.text + draft.include = event.data.include + }), + ) + } + }, + }) + + return adapter.finish() +} + +export * as SessionMessageUpdater from "./session-message-updater" diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts new file mode 100644 index 0000000000..94f6b1cac2 --- /dev/null +++ b/packages/opencode/src/v2/session-message.ts @@ -0,0 +1,178 @@ +import { Schema } from "effect" +import { Prompt } from "./session-prompt" +import { SessionEvent } from "./session-event" +import { EventV2 } from "./event" +import { ToolOutput } from "./tool-output" +import { V2Schema } from "./schema" + +export const ID = EventV2.ID +export type ID = Schema.Schema.Type + +const Base = { + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +} + +export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ + ...Base, + type: Schema.Literal("agent-switched"), + agent: SessionEvent.AgentSwitched.fields.data.fields.agent, +}) {} + +export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ + ...Base, + type: Schema.Literal("model-switched"), + model: Schema.Struct({ + id: SessionEvent.ModelSwitched.fields.data.fields.id, + providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID, + variant: SessionEvent.ModelSwitched.fields.data.fields.variant, + }), +}) {} + +export class User extends Schema.Class("Session.Message.User")({ + ...Base, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, + type: Schema.Literal("user"), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +}) {} + +export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ + ...Base, + sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID, + text: SessionEvent.Synthetic.fields.data.fields.text, + type: Schema.Literal("synthetic"), +}) {} + +export class Shell extends Schema.Class("Session.Message.Shell")({ + ...Base, + type: Schema.Literal("shell"), + callID: SessionEvent.Shell.Started.fields.data.fields.callID, + command: SessionEvent.Shell.Started.fields.data.fields.command, + output: Schema.String, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ + status: Schema.Literal("pending"), + input: Schema.String, +}) {} + +export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Unknown), + structured: ToolOutput.Structured, + content: ToolOutput.Content.pipe(Schema.Array), +}) {} + +export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Unknown), + attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, +}) {} + +export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Unknown), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), +}) {} + +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) +export type ToolState = Schema.Schema.Type + +export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ + type: Schema.Literal("tool"), + id: Schema.String, + name: Schema.String, + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + }).pipe(Schema.optional), + state: ToolState, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ + type: Schema.Literal("reasoning"), + id: Schema.String, + text: Schema.String, +}) {} + +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) +export type AssistantContent = Schema.Schema.Type + +export class Assistant extends Schema.Class("Session.Message.Assistant")({ + ...Base, + type: Schema.Literal("assistant"), + agent: Schema.String, + model: SessionEvent.Step.Started.fields.data.fields.model, + content: AssistantContent.pipe(Schema.Array), + snapshot: Schema.Struct({ + start: Schema.String.pipe(Schema.optional), + end: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + finish: Schema.String.pipe(Schema.optional), + cost: Schema.Finite.pipe(Schema.optional), + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }).pipe(Schema.optional), + error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class Compaction extends Schema.Class("Session.Message.Compaction")({ + type: Schema.Literal("compaction"), + reason: SessionEvent.Compaction.Started.fields.data.fields.reason, + summary: Schema.String, + include: Schema.String.pipe(Schema.optional), + ...Base, +}) {} + +export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Session.Message" }) + +export type Message = Schema.Schema.Type + +export type Type = Message["type"] + +export * as SessionMessage from "./session-message" diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts new file mode 100644 index 0000000000..86d8e52eb7 --- /dev/null +++ b/packages/opencode/src/v2/session-prompt.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema" + +export class Source extends Schema.Class("Prompt.Source")({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}) {} + +export class FileAttachment extends Schema.Class("Prompt.FileAttachment")({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) { + static create(input: FileAttachment) { + return new FileAttachment({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }) + } +} + +export class AgentAttachment extends Schema.Class("Prompt.AgentAttachment")({ + name: Schema.String, + source: Source.pipe(Schema.optional), +}) {} + +export class Prompt extends Schema.Class("Prompt")({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(Schema.optional), + agents: Schema.Array(AgentAttachment).pipe(Schema.optional), +}) {} diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 2bac11f4fe..1f4cbcf1e0 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -1,69 +1,280 @@ -import { Context, Layer, Schema, Effect } from "effect" -import { SessionEntry } from "./session-entry" -import { Struct } from "effect" -import { Session } from "@/session/session" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" +import { WorkspaceID } from "@/control-plane/schema" +import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" +import * as Database from "@/storage/db" +import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { SessionMessage } from "./session-message" +import type { Prompt } from "./session-prompt" +import { EventV2 } from "./event" +import { ProjectID } from "@/project/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { SessionEvent } from "./session-event" +import { V2Schema } from "./schema" +import { optionalOmitUndefined } from "@/util/schema" -export const ID = SessionID +export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ + identifier: "Session.Delivery", +}) +export type Delivery = Schema.Schema.Type -export type ID = Schema.Schema.Type - -export class PromptInput extends Schema.Class("Session.PromptInput")({ - ...Struct.omit(SessionEntry.User.fields, ["time", "type"]), - id: Schema.optionalKey(SessionEntry.ID), - sessionID: ID, -}) {} - -export class CreateInput extends Schema.Class("Session.CreateInput")({ - id: Schema.optionalKey(ID), -}) {} +export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ - id: ID, + id: SessionID, + parentID: optionalOmitUndefined(SessionID), + projectID: ProjectID, + workspaceID: optionalOmitUndefined(WorkspaceID), + path: optionalOmitUndefined(Schema.String), + agent: optionalOmitUndefined(Schema.String), model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - modelID: Schema.String, - }).pipe(Schema.optional), + id: ModelID, + providerID: ProviderID, + variant: optionalOmitUndefined(Schema.String), + }).pipe(optionalOmitUndefined), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + updated: V2Schema.DateTimeUtcFromMillis, + archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), + }), + title: Schema.String, + /* + slug: Schema.String, + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(Summary), + share: optionalOmitUndefined(Share), + title: Schema.String, + version: Schema.String, + time: Time, + permission: optionalOmitUndefined(Permission.Ruleset), + revert: optionalOmitUndefined(Revert), + */ }) {} export interface Interface { - fromID: (id: ID) => Effect.Effect - create: (input: CreateInput) => Effect.Effect - prompt: (input: PromptInput) => Effect.Effect + readonly list: (input: { + limit?: number + order?: "asc" | "desc" + directory?: string + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + cursor?: { + id: SessionID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly messages: (input: { + sessionID: SessionID + limit?: number + order?: "asc" | "desc" + cursor?: { + id: SessionMessage.ID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly context: (sessionID: SessionID) => Effect.Effect + readonly prompt: (input: { + id?: EventV2.ID + sessionID: SessionID + prompt: Prompt + delivery?: Delivery + }) => Effect.Effect + readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect + readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect + readonly switchModel: (input: { + sessionID: SessionID + id: ModelID + providerID: ProviderID + variant?: string + }) => Effect.Effect + readonly compact: (sessionID: SessionID) => Effect.Effect + readonly wait: (sessionID: SessionID) => Effect.Effect } -export class Service extends Context.Service()("Session.Service") {} +export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.effect(Service)( +export const layer = Layer.effect( + Service, Effect.gen(function* () { - const session = yield* Session.Service + const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) - const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) { - throw new Error("Not implemented") - }) + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }) - const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) { - throw new Error("Not implemented") - }) + function fromRow(row: typeof SessionTable.$inferSelect): Info { + return new Info({ + id: SessionID.make(row.id), + projectID: ProjectID.make(row.project_id), + workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, + title: row.title, + parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined, + path: row.path ?? "", + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } + : undefined, + time: { + created: DateTime.makeUnsafe(row.time_created), + updated: DateTime.makeUnsafe(row.time_updated), + archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, + }, + }) + } - const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) { - const match = yield* session.get(id) - return fromV1(match) - }) + const result: Interface = { + list: Effect.fn("V2Session.list")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const conditions: SQL[] = [] + if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.path) + conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if (input.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input.start) conditions.push(gte(SessionTable.time_created, input.start)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.cursor) { + conditions.push( + order === "asc" + ? or( + gt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), gt(SessionTable.id, input.cursor.id)), + )! + : or( + lt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), lt(SessionTable.id, input.cursor.id)), + )!, + ) + } + const query = Database.Client() + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(SessionTable.time_created) : desc(SessionTable.time_created), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) - return Service.of({ - create, - prompt, - fromID, - }) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const boundary = input.cursor + ? order === "asc" + ? or( + gt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + gt(SessionMessageTable.id, input.cursor.id), + ), + ) + : or( + lt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + lt(SessionMessageTable.id, input.cursor.id), + ), + ) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + + const rows = Database.use((db) => { + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy( + order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), + order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), + ) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return direction === "previous" ? rows.toReversed() : rows + }) + return rows.map((row) => decode(row)) + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + const rows = Database.use((db) => { + const compaction = db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) + .limit(1) + .get() + + return db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, sessionID), + compaction + ? or( + gt(SessionMessageTable.time_created, compaction.time_created), + and( + eq(SessionMessageTable.time_created, compaction.time_created), + gte(SessionMessageTable.id, compaction.id), + ), + ) + : undefined, + ), + ) + .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) + .all() + }) + return rows.map((row) => decode(row)) + }), + prompt: Effect.fn("V2Session.prompt")(function* (_input) { + return {} as any + }), + shell: Effect.fn("V2Session.shell")(function* (_input) {}), + skill: Effect.fn("V2Session.skill")(function* (_input) {}), + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + agent: input.agent, + }) + }), + switchModel: Effect.fn("V2Session.switchModel")(function* (input) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + id: input.id, + providerID: input.providerID, + variant: input.variant, + }) + }), + compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), + wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), + } + + return Service.of(result) }), ) -function fromV1(input: Session.Info): Info { - return new Info({ - id: ID.make(input.id), - }) -} +export const defaultLayer = layer export * as SessionV2 from "./session" diff --git a/packages/opencode/src/v2/tool-output.ts b/packages/opencode/src/v2/tool-output.ts new file mode 100644 index 0000000000..dee2bb11ed --- /dev/null +++ b/packages/opencode/src/v2/tool-output.ts @@ -0,0 +1,18 @@ +export * as ToolOutput from "./tool-output" +import { Schema } from "effect" + +export class TextContent extends Schema.Class("Tool.TextContent")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class FileContent extends Schema.Class("Tool.FileContent")({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), +}) {} + +export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type")) + +export const Structured = Schema.Record(Schema.String, Schema.Any) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 2e9b6736f5..f4e4d2721c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,7 +1,8 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { InstanceLayer } from "@/project/instance-layer" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -159,7 +160,12 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service + | AppFileSystem.Service + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Git.Service + | Project.Service + | InstanceStore.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -169,6 +175,7 @@ export const layer: Layer.Layer< const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const gitSvc = yield* Git.Service const project = yield* Project.Service + const store = yield* InstanceStore.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -251,13 +258,10 @@ export const layer: Layer.Layer< return } - const booted = yield* Effect.promise(() => - Instance.provide({ - directory: info.directory, - fn: () => undefined, - }) - .then(() => true) - .catch((error) => { + const booted = yield* store.load({ directory: info.directory }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.sync(() => { const message = errorMessage(error) log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { @@ -268,6 +272,7 @@ export const layer: Layer.Layer< }) return false }), + ), ) if (!booted) return @@ -286,16 +291,15 @@ export const layer: Layer.Layer< const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { yield* setup(info) - yield* boot(info, startCommand) + yield* boot(info, startCommand).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), + Effect.forkIn(scope), + ) }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { const info = yield* makeWorktreeInfo(input?.name) - yield* setup(info) - yield* boot(info, input?.startCommand).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), - Effect.forkIn(scope), - ) + yield* createFromInfo(info, input?.startCommand) return info }) @@ -579,7 +583,7 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( +export const appLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Project.defaultLayer), @@ -587,4 +591,6 @@ export const defaultLayer = layer.pipe( Layer.provide(NodePath.layer), ) +export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) + export * as Worktree from "." diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 00564a17bf..41372b15a0 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s ```typescript import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) describe("my service", () => { - it.live("does the thing", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const svc = yield* MyService.Service - const out = yield* svc.run() - expect(out).toEqual("ok") - }), - ), + it.instance("does the thing", () => + Effect.gen(function* () { + const svc = yield* MyService.Service + const out = yield* svc.run() + expect(out).toEqual("ok") + }), ) }) ``` @@ -111,6 +108,7 @@ describe("my service", () => { - Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. - Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. +- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context. - Most integration-style tests in this package use `it.live(...)`. ### Effect Fixtures @@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. -Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test. +Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path: + +```typescript +import { TestInstance } from "../fixture/fixture" + +it.instance("uses the temp directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect(test.directory).toContain("opencode-test-") + }), +) +``` + +Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime. ### Style @@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst - Keep the test body inside `Effect.gen(function* () { ... })`. - Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. -- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests. +- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests. diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 8fdf1a6cba..d3e6f4047a 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -3,6 +3,7 @@ import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@kilocode/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" type SessionUpdateParams = Parameters[0] @@ -58,6 +59,7 @@ function toolEvent( raw: opts.raw, } const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, type: "message.part.updated", properties: { sessionID: sessionId, @@ -262,7 +264,7 @@ function createFakeAgent() { describe("acp.agent event subscription", () => { test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, updates, stop } = createFakeAgent() @@ -297,7 +299,7 @@ describe("acp.agent event subscription", () => { test("does not emit user_message_chunk for live prompt parts", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -337,7 +339,7 @@ describe("acp.agent event subscription", () => { test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, chunks, stop } = createFakeAgent() @@ -389,7 +391,7 @@ describe("acp.agent event subscription", () => { test("does not create additional event subscriptions on repeated loadSession()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, calls, stop } = createFakeAgent() @@ -411,7 +413,7 @@ describe("acp.agent event subscription", () => { test("permission.asked events are handled and replied", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -450,7 +452,7 @@ describe("acp.agent event subscription", () => { test("permission prompt on session A does not block message updates for session B", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -537,7 +539,7 @@ describe("acp.agent event subscription", () => { test("streams running bash output snapshots and de-dupes identical snapshots", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -571,7 +573,7 @@ describe("acp.agent event subscription", () => { test("emits synthetic pending before first running update for any tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -616,7 +618,7 @@ describe("acp.agent event subscription", () => { test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() @@ -675,7 +677,7 @@ describe("acp.agent event subscription", () => { test("clears bash snapshot marker on pending state", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index d3c09b73aa..2cff1f48cd 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import path from "path" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" @@ -23,7 +24,7 @@ afterEach(async () => { test("returns default native agents when no config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agents = await load(tmp.path, (svc) => svc.list()) @@ -41,7 +42,7 @@ test("returns default native agents when no config", async () => { test("build agent has correct default properties", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -56,7 +57,7 @@ test("build agent has correct default properties", async () => { test("plan agent denies edits except .opencode/plans/*", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const plan = await load(tmp.path, (svc) => svc.get("plan")) @@ -71,7 +72,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => { test("explore agent denies edit and write", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -87,7 +88,7 @@ test("explore agent denies edit and write", async () => { test("explore agent asks for external directories and allows whitelisted external paths", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -103,7 +104,7 @@ test("explore agent asks for external directories and allows whitelisted externa test("general agent denies todo tools", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const general = await load(tmp.path, (svc) => svc.get("general")) @@ -117,7 +118,7 @@ test("general agent denies todo tools", async () => { test("compaction agent denies all permissions", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const compaction = await load(tmp.path, (svc) => svc.get("compaction")) @@ -143,7 +144,7 @@ test("custom agent from config creates new agent", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent")) @@ -172,7 +173,7 @@ test("custom agent config overrides native agent properties", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -195,7 +196,7 @@ test("agent disable removes agent from list", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -221,7 +222,7 @@ test("agent permission config merges with defaults", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -242,7 +243,7 @@ test("global permission config applies to all agents", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -261,7 +262,7 @@ test("agent steps/maxSteps config sets steps property", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -280,7 +281,7 @@ test("agent mode can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -297,7 +298,7 @@ test("agent name can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -314,7 +315,7 @@ test("agent prompt can be set from config", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -334,7 +335,7 @@ test("unknown agent properties are placed into options", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -357,7 +358,7 @@ test("agent options merge correctly", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -382,7 +383,7 @@ test("multiple custom agents can be defined", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agentA = await load(tmp.path, (svc) => svc.get("agent_a")) @@ -411,7 +412,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name) @@ -423,7 +424,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn test("Agent.get returns undefined for non-existent agent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist")) @@ -434,7 +435,7 @@ test("Agent.get returns undefined for non-existent agent", async () => { test("default permission includes doom_loop and external_directory as ask", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -446,7 +447,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn test("webfetch is allowed by default", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -468,7 +469,7 @@ test("legacy tools config converts to permissions", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -490,7 +491,7 @@ test("legacy tools config maps write/edit/patch to edit permission", async () => }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -508,7 +509,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -521,7 +522,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally test("global tmp directory children are allowed for external_directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -546,7 +547,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -569,7 +570,7 @@ test("explicit Truncate.GLOB deny is respected", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -601,7 +602,7 @@ description: Permission skill. process.env.KILO_TEST_HOME = tmp.path try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -617,7 +618,7 @@ description: Permission skill. test("defaultAgent returns build when no default_agent config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -632,7 +633,7 @@ test("defaultAgent respects default_agent config set to plan", async () => { default_agent: "plan", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -652,7 +653,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -667,7 +668,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => { default_agent: "explore", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent') @@ -681,7 +682,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () = default_agent: "compaction", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden') @@ -695,7 +696,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn default_agent: "does_not_exist", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow( @@ -713,7 +714,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -732,7 +733,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // build and plan are disabled, no primary-capable agents remain diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts new file mode 100644 index 0000000000..dff972d100 --- /dev/null +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -0,0 +1,28 @@ +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import { pathToFileURL } from "url" +import { Agent } from "../../src/agent/agent" +import { Plugin } from "../../src/plugin" +import { testEffect } from "../lib/effect" +import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" + +// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin +// up — those services hang during scope teardown on Windows and aren't needed +// to verify plugin → config hook → Agent.list. +const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href + +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer)) + +it.instance( + "plugin-registered agents appear in Agent.list", + () => + Effect.gen(function* () { + yield* Plugin.Service.use((p) => p.init()) + const agents = yield* Agent.Service.use((svc) => svc.list()) + const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name) + expect(added?.description).toBe(PLUGIN_AGENT.description) + expect(added?.mode).toBe(PLUGIN_AGENT.mode) + }), + { config: { plugin: [pluginUrl] } }, +) diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 101d3be72b..377c541096 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -2,9 +2,8 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const TestEvent = { @@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node) const it = testEffect(live) describe("Bus (Effect-native)", () => { - it.live("publish + subscribe stream delivers events", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() + it.instance("publish + subscribe stream delivers events", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Ping, { value: 2 }) + yield* Deferred.await(done) - expect(received).toEqual([1, 2]) - }), - ), + expect(received).toEqual([1, 2]) + }), ) - it.live("subscribe filters by event type", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() + it.instance("subscribe filters by event type", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const pings: number[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + pings.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Pong, { message: "ignored" }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Pong, { message: "ignored" }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* Deferred.await(done) - expect(pings).toEqual([42]) - }), - ), + expect(pings).toEqual([42]) + }), ) - it.live("subscribeAll receives all types", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const types: string[] = [] - const done = yield* Deferred.make() + it.instance("subscribeAll receives all types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const types: string[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribeAll(), (evt) => - Effect.sync(() => { - types.push(evt.type) - if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribeAll(), (evt) => + Effect.sync(() => { + types.push(evt.type) + if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Pong, { message: "hi" }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Pong, { message: "hi" }) + yield* Deferred.await(done) - expect(types).toContain("test.effect.ping") - expect(types).toContain("test.effect.pong") - }), - ), + expect(types).toContain("test.effect.ping") + expect(types).toContain("test.effect.pong") + }), ) - it.live("multiple subscribers each receive the event", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const a: number[] = [] - const b: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() + it.instance("multiple subscribers each receive the event", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const a: number[] = [] + const b: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + a.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + b.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(doneA) - yield* Deferred.await(doneB) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 99 }) + yield* Deferred.await(doneA) + yield* Deferred.await(doneB) - expect(a).toEqual([99]) - expect(b).toEqual([99]) - }), - ), + expect(a).toEqual([99]) + expect(b).toEqual([99]) + }), ) it.live("subscribeAll stream sees InstanceDisposed on disposal", () => diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 7e2138ea81..3e3d7a3e90 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -3,12 +3,13 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus integration: acquireRelease subscriber pattern", () => { diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index b24b79b33b..876cb1ed74 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = { @@ -11,7 +12,7 @@ const TestEvent = { } function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus", () => { diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts new file mode 100644 index 0000000000..de6fed8daa --- /dev/null +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +// Regression for PR #25522: when an effectCmd handler does +// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`, +// the inner runPromise creates a fresh fiber after `await` whose Effect context +// has lost the outer InstanceRef. Services that read `InstanceState.context` +// then fall back to `Instance.current` ALS, which must be installed at the JS +// callback boundary (Node ALS persists across awaits, Effect's fiber context +// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap. +// Pins effect-cmd.ts directly: the pattern test below exercises the load + +// Instance.restore + dispose triple via the shared `provideTestInstance` fixture, +// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't +// fail it. This grep guards the actual production callsite. +test("effect-cmd.ts wraps the handler body in Instance.restore", async () => { + const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8") + expect(source).toContain("Instance.restore(ctx") +}) + +test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => { + await using dir = await tmpdir({ git: true }) + await provideTestInstance({ + directory: dir.path, + fn: () => + Effect.runPromise( + Effect.promise(async () => { + await new Promise((r) => setTimeout(r, 5)) + const current = await Effect.runPromise( + Effect.sync(() => { + try { + return Instance.current + } catch { + return undefined + } + }), + ) + expect(current?.directory).toBe(dir.path) + }), + ), + }) +}) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index e4a0ca8b06..97dedc6489 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -25,6 +25,7 @@ function event(payload: Event, input: { directory: string; workspace?: string }) function vcs(branch: string): Event { return { + id: `evt_vcs_${branch}`, type: "vcs.branch.updated", properties: { branch, @@ -34,6 +35,7 @@ function vcs(branch: string): Event { function update(version: string): Event { return { + id: `evt_update_${version}`, type: "installation.update-available", properties: { version, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 2bc9334e60..f45979ac85 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -7,13 +7,15 @@ import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" +import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -41,6 +43,12 @@ const emptyAuth = Layer.mock(Auth.Service)({ const testFlock = EffectFlock.defaultLayer +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + which: () => Effect.succeed(Option.none()), +}) + const layer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), @@ -48,7 +56,7 @@ const layer = Config.layer.pipe( Layer.provide(emptyAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) const it = testEffect(layer) @@ -57,9 +65,17 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe const save = (config: Config.Info) => Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) const saveGlobal = (config: Config.Info) => - Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer))) -const clear = (wait = false) => - Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) + Effect.runPromise( + Config.Service.use((svc) => svc.updateGlobal(config)).pipe( + Effect.map((result) => result.info), + Effect.scoped, + Effect.provide(layer), + ), + ) +const clear = async (wait = false) => { + await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer))) + if (wait) await InstanceRuntime.disposeAllInstances() +} const listDirs = () => Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) const ready = () => @@ -98,7 +114,7 @@ async function check(map: (dir: string) => string) { $schema: "https://opencode.ai/config.json", snapshot: false, }) - await Instance.provide({ + await WithInstance.provide({ directory: map(tmp.path), fn: async () => { const cfg = await load() @@ -108,7 +124,7 @@ async function check(map: (dir: string) => string) { }, }) } finally { - await disposeAllInstances() + await InstanceRuntime.disposeAllInstances() ;(Global.Path as { config: string }).config = prev await clear() } @@ -116,7 +132,7 @@ async function check(map: (dir: string) => string) { test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -135,7 +151,7 @@ test("loads JSON config file", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -154,7 +170,7 @@ test("loads shell config field", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -176,7 +192,7 @@ test("updates config and preserves empty shell sentinel", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await save({ shell: "" }) @@ -254,7 +270,7 @@ test("loads formatter boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -272,7 +288,7 @@ test("loads lsp boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -309,7 +325,7 @@ test("ignores legacy tui keys in opencode config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -334,7 +350,7 @@ test("loads JSONC config file", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -362,7 +378,7 @@ test("jsonc overrides json in the same directory", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -385,7 +401,7 @@ test("handles environment variable substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -417,7 +433,7 @@ test("preserves env variables when adding $schema to config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -483,6 +499,7 @@ test("resolves env templates in account config with account token", async () => Layer.provide(emptyAuth), Layer.provide(fakeAccount), Layer.provideMerge(infra), + Layer.provide(noopNpm), ) try { @@ -493,7 +510,7 @@ test("resolves env templates in account config with account token", async () => expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") }), ), - ).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise) + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) } finally { if (originalControlToken !== undefined) { process.env["KILO_CONSOLE_TOKEN"] = originalControlToken @@ -513,7 +530,7 @@ test("handles file inclusion substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -532,7 +549,7 @@ test("handles file inclusion with replacement tokens", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -550,7 +567,7 @@ test("validates config schema and throws on invalid fields", async () => { }) }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { // Strict schema should throw an error for invalid fields @@ -565,7 +582,7 @@ test("throws error for invalid JSON", async () => { await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { await expect(load()).rejects.toThrow() @@ -588,7 +605,7 @@ test("handles agent configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -619,7 +636,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -649,7 +666,7 @@ test("handles command configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -674,7 +691,7 @@ test("migrates autoshare to share field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -701,7 +718,7 @@ test("migrates mode field to agent field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -733,7 +750,7 @@ Test agent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -766,7 +783,7 @@ Ordered permissions`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -804,7 +821,7 @@ Nested agent prompt`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -853,7 +870,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -898,7 +915,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -918,7 +935,7 @@ Nested command template`, test("updates config and writes to file", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } @@ -932,7 +949,7 @@ test("updates config and writes to file", async () => { test("gets config directories", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const dirs = await listDirs() @@ -962,7 +979,7 @@ test("does not try to install dependencies in read-only KILO_CONFIG_DIR", async process.env.KILO_CONFIG_DIR = tmp.extra try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await load() @@ -986,11 +1003,6 @@ test("installs dependencies in writable KILO_CONFIG_DIR", async () => { const prev = process.env.KILO_CONFIG_DIR process.env.KILO_CONFIG_DIR = tmp.extra - const noopNpm = Layer.mock(Npm.Service)({ - install: () => Effect.void, - add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), - }) const testLayer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), @@ -1002,7 +1014,7 @@ test("installs dependencies in writable KILO_CONFIG_DIR", async () => { ) try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer))) @@ -1061,7 +1073,7 @@ test("resolves scoped npm plugins in config", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { const config = await load() @@ -1099,7 +1111,7 @@ test("merges plugin arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1135,7 +1147,7 @@ Helper subagent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1174,7 +1186,7 @@ test("merges instructions arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1213,7 +1225,7 @@ test("deduplicates duplicate instructions from global and local configs", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1258,7 +1270,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1307,7 +1319,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const cfg = await load() @@ -1348,7 +1360,7 @@ test("migrates legacy tools config to permissions - allow", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1379,7 +1391,7 @@ test("migrates legacy tools config to permissions - deny", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1409,7 +1421,7 @@ test("migrates legacy write tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1441,7 +1453,7 @@ test("managed settings override user settings", async () => { share: "disabled", }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1469,7 +1481,7 @@ test("managed settings override project settings", async () => { disabled_providers: ["openai"], }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1489,7 +1501,7 @@ test("missing managed settings file is not an error", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1516,7 +1528,7 @@ test("migrates legacy edit tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1545,7 +1557,7 @@ test("migrates legacy patch tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1577,7 +1589,7 @@ test("migrates mixed legacy tools config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1612,7 +1624,7 @@ test("merges legacy tools with existing permission config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1649,7 +1661,7 @@ test("permission config preserves user key order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1732,7 +1744,7 @@ test("project config can override MCP server enabled status", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1788,7 +1800,7 @@ test("MCP config deep merges preserving base config properties", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1839,7 +1851,7 @@ test("local .opencode config can override MCP from project config", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1883,7 +1895,7 @@ test("project config overrides remote well-known config", async () => { Layer.provide(fakeAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) try { @@ -1941,7 +1953,7 @@ test("wellknown URL with trailing slash is normalized", async () => { Layer.provide(fakeAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) try { @@ -2096,7 +2108,7 @@ describe("deduplicatePluginOrigins", () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -2128,7 +2140,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2159,7 +2171,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => { await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const directories = await listDirs() @@ -2183,7 +2195,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should still get default config (from global or defaults) @@ -2225,7 +2237,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // The relative instruction should be skipped without error @@ -2285,7 +2297,7 @@ describe("KILO_DISABLE_PROJECT_CONFIG", () => { process.env["KILO_DISABLE_PROJECT_CONFIG"] = "true" process.env["KILO_CONFIG_DIR"] = configDirTmp.path - await Instance.provide({ + await WithInstance.provide({ directory: projectTmp.path, fn: async () => { const config = await load() @@ -2320,7 +2332,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2354,7 +2366,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index a10cfe8721..37f33a4f99 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { provideTestInstance, tmpdir } from "../fixture/fixture" +import { InstanceRuntime } from "@/project/instance-runtime" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" @@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" import { ConfigPlugin } from "@/config/plugin" const wintest = process.platform === "win32" ? test : test.skip -const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) +const clear = async (wait = false) => { + await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate())) + if (wait) await InstanceRuntime.disposeAllInstances() +} const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) beforeEach(async () => { @@ -87,7 +90,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { const server = await load() @@ -624,3 +627,43 @@ test("merges plugin_enabled flags across config layers", async () => { "local.plugin": true, }) }) + +test("silently skips malformed tui.json — load failures degrade to {}", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",') + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) + }, + }) + + const config = await getTuiConfig(tmp.path) + // Project tui.json is malformed → silently skipped (logs a warning) + // .opencode/tui.json (lower precedence in this path) still loads + expect(config.theme).toBe("fallback") +}) + +test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // tui.json exists as a DIRECTORY rather than a file → readFileString fails + // with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes + // that through catchCause → log + skip, so a fallback layer should still load. + await fs.mkdir(path.join(dir, "tui.json"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) + }, + }) + + const config = await getTuiConfig(tmp.path) + // Did NOT crash; .opencode/tui.json (lower precedence) still loads. + expect(config.theme).toBe("fallback") +}) + +test("missing tui.json — silently treated as empty (ENOENT path)", async () => { + await using tmp = await tmpdir({}) + + // No tui.json anywhere. Should not throw. + const config = await getTuiConfig(tmp.path) + expect(config).toBeDefined() + // No theme set anywhere. + expect(config.theme).toBeUndefined() +}) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 08656ef63a..ae2eb102f2 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -14,6 +14,7 @@ import { Database } from "@/storage/db" import { ProjectID } from "@/project/schema" import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { SessionID, MessageID, PartID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" @@ -101,7 +102,7 @@ afterEach(async () => { async function withInstance(fn: (dir: string) => T | Promise) { await using tmp = await tmpdir({ git: true }) - return Instance.provide({ + return WithInstance.provide({ directory: tmp.path, fn: () => fn(tmp.path), }) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 0a8972ca4a..f5e6933883 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -3,9 +3,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" -import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(CrossSpawnSpawner.defaultLayer) @@ -70,7 +69,7 @@ it.live("InstanceState invalidates on reload", () => ) const a = yield* access(state, dir) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const b = yield* access(state, dir) expect(a).not.toBe(b) @@ -270,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () => const [, b] = yield* Effect.all( [ - Effect.promise(() => InstanceStore.reloadInstance({ directory: one })), + Effect.promise(() => reloadTestInstance({ directory: one })), Test.use((svc) => svc.get()).pipe(provideInstance(two)), ], { concurrency: "unbounded" }, diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 699e713c22..f345cd0850 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import path from "path" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -30,7 +31,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await status() @@ -55,7 +56,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await read("tracked.txt") diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index bf5e7a175f..cdd2e211c2 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -5,6 +5,7 @@ import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" @@ -28,7 +29,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "Hello World", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -41,7 +42,7 @@ describe("file/index Filesystem patterns", () => { test("reads with Filesystem.exists() check", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Non-existent file should return empty content @@ -57,7 +58,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, " content with spaces \n\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -71,7 +72,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "empty.txt") await fs.writeFile(filepath, "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("empty.txt") @@ -86,7 +87,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "multiline.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("multiline.txt") @@ -103,7 +104,7 @@ describe("file/index Filesystem patterns", () => { const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) await fs.writeFile(filepath, binaryContent) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("image.png") @@ -120,7 +121,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "binary.so") await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("binary.so") @@ -137,7 +138,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.json") await fs.writeFile(filepath, '{"key": "value"}', "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain("application/json") @@ -161,7 +162,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, `test.${ext}`) await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain(mime) @@ -175,7 +176,7 @@ describe("file/index Filesystem patterns", () => { test("reads .gitignore via Filesystem.exists() and readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -193,7 +194,7 @@ describe("file/index Filesystem patterns", () => { test("reads .ignore file similarly", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ignorePath = path.join(tmp.path, ".ignore") @@ -208,7 +209,7 @@ describe("file/index Filesystem patterns", () => { test("handles missing .gitignore gracefully", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -226,7 +227,7 @@ describe("file/index Filesystem patterns", () => { test("reads untracked files via Filesystem.readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const untrackedPath = path.join(tmp.path, "untracked.txt") @@ -247,7 +248,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "readonly.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.txt") @@ -264,7 +265,7 @@ describe("file/index Filesystem patterns", () => { test("handles errors in Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.bin") @@ -279,7 +280,7 @@ describe("file/index Filesystem patterns", () => { const _filepath = path.join(tmp.path, "broken.png") // Don't create the file - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // read() handles missing images gracefully @@ -297,7 +298,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.ts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.ts") @@ -312,7 +313,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.mts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.mts") @@ -327,7 +328,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.sh") await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.sh") @@ -342,7 +343,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "Dockerfile") await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("Dockerfile") @@ -357,7 +358,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "simple text", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -372,7 +373,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.jpg") await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.jpg") @@ -387,7 +388,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -398,7 +399,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -416,7 +417,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -433,7 +434,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -454,7 +455,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.rm(filepath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -477,7 +478,7 @@ describe("file/index Filesystem patterns", () => { await fs.rm(path.join(tmp.path, "remove.txt")) await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -491,7 +492,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for non-git project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -503,7 +504,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for clean repo", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -526,7 +527,7 @@ describe("file/index Filesystem patterns", () => { for (let i = 0; i < 512; i++) modified[i] = i % 256 await fs.writeFile(filepath, modified) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -547,7 +548,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -571,7 +572,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -596,7 +597,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -615,7 +616,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") await fs.mkdir(path.join(tmp.path, "build")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -635,7 +636,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list("sub") @@ -650,7 +651,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../outside")).rejects.toThrow("Access denied") @@ -662,7 +663,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir() await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -692,7 +693,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -706,7 +707,7 @@ describe("file/index Filesystem patterns", () => { test("search works before explicit init", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await search({ query: "main", type: "file" }) @@ -718,7 +719,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns dirs sorted with hidden last", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -738,7 +739,7 @@ describe("file/index Filesystem patterns", () => { test("fuzzy matches file names", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -752,7 +753,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -769,7 +770,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only directories", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -786,7 +787,7 @@ describe("file/index Filesystem patterns", () => { test("respects limit", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -800,7 +801,7 @@ describe("file/index Filesystem patterns", () => { test("query starting with dot prefers hidden files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -815,7 +816,7 @@ describe("file/index Filesystem patterns", () => { test("search refreshes after init when files change", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -839,7 +840,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified content\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("file.txt") @@ -863,7 +864,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(filepath, "after\n", "utf-8") await $`git add .`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("staged.txt") @@ -880,7 +881,7 @@ describe("file/index Filesystem patterns", () => { await $`git add .`.cwd(tmp.path).quiet() await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("clean.txt") @@ -900,7 +901,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: one.path, fn: async () => { await init() @@ -911,7 +912,7 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: two.path, fn: async () => { await init() @@ -927,7 +928,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -941,7 +942,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") await fs.rm(path.join(tmp.path, "before.ts")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 3a5ce2323e..5b59929ea5 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" @@ -55,7 +56,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") @@ -66,7 +67,7 @@ describe("File.read path traversal protection", () => { test("rejects deeply nested traversal", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( @@ -83,7 +84,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("valid.txt") @@ -97,7 +98,7 @@ describe("File.list path traversal protection", () => { test("rejects ../ traversal attempting to list /etc", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") @@ -112,7 +113,7 @@ describe("File.list path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await list("subdir") @@ -126,7 +127,7 @@ describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) @@ -140,7 +141,7 @@ describe("containsPath", () => { const subdir = path.join(tmp.path, "packages", "lib") await fs.mkdir(subdir, { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib @@ -156,7 +157,7 @@ describe("containsPath", () => { test("returns false for path outside both directory and worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath("/etc/passwd", Instance.current)).toBe(false) @@ -168,7 +169,7 @@ describe("containsPath", () => { test("returns false for path with .. escaping worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) @@ -179,7 +180,7 @@ describe("containsPath", () => { test("handles directory === worktree (running from repo root)", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) @@ -192,7 +193,7 @@ describe("containsPath", () => { test("non-git project does not allow arbitrary paths via worktree='/'", async () => { await using tmp = await tmpdir() // no git: true - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index ef71483661..95fac0d1be 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -9,6 +9,7 @@ import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -28,7 +29,7 @@ type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ function withWatcher(directory: string, body: Effect.Effect) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( @@ -193,7 +194,7 @@ describeWatcher("FileWatcher", () => { await withWatcher(tmp.path, Effect.void) // Now write a file — no watcher should be listening - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/fixture/agent-plugin.constants.ts b/packages/opencode/test/fixture/agent-plugin.constants.ts new file mode 100644 index 0000000000..9dd5f3910e --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.constants.ts @@ -0,0 +1,6 @@ +// Separate file because every export in `agent-plugin.ts` must be a function. +export const PLUGIN_AGENT = { + name: "plugin_added", + description: "Added by a plugin via the config hook", + mode: "subagent", +} as const diff --git a/packages/opencode/test/fixture/agent-plugin.ts b/packages/opencode/test/fixture/agent-plugin.ts new file mode 100644 index 0000000000..892f636466 --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.ts @@ -0,0 +1,12 @@ +// Every export in this file must be a plugin function — `getLegacyPlugins` +// (src/plugin/index.ts) throws on anything else. Test constants live in +// `agent-plugin.constants.ts`. +export default async () => ({ + config: async (cfg: { agent?: Record }) => { + cfg.agent = cfg.agent ?? {} + cfg.agent["plugin_added"] = { + description: "Added by a plugin via the config hook", + mode: "subagent", + } + }, +}) diff --git a/packages/opencode/test/fixture/config.ts b/packages/opencode/test/fixture/config.ts new file mode 100644 index 0000000000..4cd90c51bf --- /dev/null +++ b/packages/opencode/test/fixture/config.ts @@ -0,0 +1,23 @@ +import { Config } from "@/config/config" +import { emptyConsoleState } from "@/config/console-state" +import { Effect, Layer } from "effect" + +export function make(overrides: Partial = {}) { + return Config.Service.of({ + get: () => Effect.succeed({}), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed(emptyConsoleState), + update: () => Effect.void, + updateGlobal: (config) => Effect.succeed({ info: config, changed: false }), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + ...overrides, + }) +} + +export function layer(overrides?: Partial) { + return Layer.succeed(Config.Service, make(overrides)) +} + +export * as TestConfig from "./config" diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 1b193e382a..d47620f623 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -1,20 +1,46 @@ import { $ } from "bun" +import * as Observability from "@opencode-ai/core/effect/observability" import * as fs from "fs/promises" import os from "os" import path from "path" -import { Effect, Context } from "effect" +import { Effect, Context, Layer, ManagedRuntime } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" -// Re-export for test ergonomics. The implementation lives next to the runtime -// it consumes; see `InstanceStore.disposeAllInstances` for the rationale. -export { disposeAllInstances } from "../../src/project/instance-store" +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const testInstanceRuntime = ManagedRuntime.make( + InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)), +) + +const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => + testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) + +export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { + const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) + try { + if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) + return await Instance.restore(ctx, () => input.fn()) + } finally { + await runTestInstanceStore((store) => store.dispose(ctx)) + } +} + +export async function reloadTestInstance(input: { directory: string }) { + return runTestInstanceStore((store) => store.reload(input)) +} + +export async function disposeAllInstances() { + await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())]) +} // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { @@ -129,12 +155,12 @@ export const provideInstance = (directory: string) => (self: Effect.Effect): Effect.Effect => Effect.contextWith((services: Context.Context) => - Effect.promise(async () => - Instance.provide({ - directory, - fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))), - }), - ), + Effect.promise(async () => { + const ctx = await runTestInstanceStore((store) => store.load({ directory })) + return Instance.restore(ctx, () => + Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))), + ) + }), ) export function provideTmpdirInstance( @@ -148,10 +174,9 @@ export function provideTmpdirInstance( yield* Effect.addFinalizer(() => provided ? Effect.promise(() => - Instance.provide({ - directory: path, - fn: () => InstanceStore.disposeInstance(Instance.current), - }), + runTestInstanceStore((store) => + store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))), + ), ).pipe(Effect.ignore) : Effect.void, ) @@ -161,6 +186,21 @@ export function provideTmpdirInstance( }) } +export class TestInstance extends Context.Service()("@test/Instance") {} + +export const withTmpdirInstance = + (options?: { git?: boolean; config?: Partial }) => + (self: Effect.Effect) => + Effect.gen(function* () { + const directory = yield* tmpdirScoped(options) + return yield* InstanceStore.Service.use((store) => + store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))), + ) + }).pipe( + Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))), + Effect.provide(CrossSpawnSpawner.defaultLayer), + ) + export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, options?: { git?: boolean; config?: (url: string) => Partial }, diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index a897a38e68..1e56865d72 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -114,6 +114,53 @@ describe("Git", () => { }) }) + test("patch() returns capped native patch output", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8") + + await withGit(async (rt) => { + const [patch, all, capped] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("-before") + expect(patch.text).toContain("+after") + expect(all.truncated).toBe(false) + expect(all.text).toContain("diff --git") + expect(all.text).toContain("other.txt") + expect(all.text).toContain("+new") + expect(capped.truncated).toBe(true) + expect(capped.text).toBe("") + }) + }) + + test("patchUntracked() and statUntracked() handle added files", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8") + + await withGit(async (rt) => { + const [patch, stat] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("+one") + expect(patch.text).toContain("+two") + expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 })) + }) + }) + test("show() returns empty text for binary blobs", async () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])) diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 131ec5cc6b..e454fa7e42 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -3,8 +3,24 @@ import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" +import type { Config } from "@/config/config" +import { TestInstance, withTmpdirInstance } from "../fixture/fixture" type Body = Effect.Effect | (() => Effect.Effect) +type InstanceOptions = { git?: boolean; config?: Partial } + +function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions { + return !!options && typeof options === "object" && ("git" in options || "config" in options) +} + +function instanceArgs( + options?: InstanceOptions | number | TestOptions, + testOptions?: number | TestOptions, +): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } { + if (typeof options === "number") return { instanceOptions: undefined, testOptions: options } + if (isInstanceOptions(options)) return { instanceOptions: options, testOptions } + return { instanceOptions: undefined, testOptions: options } +} const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) @@ -38,7 +54,49 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) live.skip = (name: string, value: Body, opts?: number | TestOptions) => test.skip(name, () => run(value, liveLayer), opts) - return { effect, live } + const instance = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) + } + + instance.only = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.only( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) + } + + instance.skip = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.skip( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) + } + + return { effect, live, instance } } // Test environment with TestClock and TestConsole diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f3c7893d97..7d9f5a7155 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" function spawnFakeServer() { @@ -25,7 +26,7 @@ describe("LSPClient interop", () => { test("handles workspace/workspaceFolders request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -48,7 +49,7 @@ describe("LSPClient interop", () => { test("handles client/registerCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -71,7 +72,7 @@ describe("LSPClient interop", () => { test("handles client/unregisterCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -94,7 +95,7 @@ describe("LSPClient interop", () => { test("initialize does not overclaim unsupported diagnostics capabilities", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -121,7 +122,7 @@ describe("LSPClient interop", () => { gamma: true, } - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -150,7 +151,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "first\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -193,7 +194,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -239,7 +240,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -286,7 +287,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -334,7 +335,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -387,7 +388,7 @@ describe("LSPClient interop", () => { await Bun.write(file, "class C {}\n") await Bun.write(related, "class D {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -451,7 +452,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 175717d056..5bc8f803d2 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -48,6 +48,7 @@ beforeEach(() => { const { MCP } = await import("../../src/mcp/index") const { AppRuntime } = await import("../../src/effect/app-runtime") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -73,7 +74,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Trigger MCP initialization - it will fail to connect but we can check the transport options @@ -112,7 +113,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( test("headers are passed to transports when oauth is explicitly disabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 @@ -150,7 +151,7 @@ test("headers are passed to transports when oauth is explicitly disabled", async test("no requestInit when headers are not provided", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 59fa54ceab..10547c9f08 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,5 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Effect } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -172,6 +172,7 @@ beforeEach(() => { // Import after mocks const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") // --- Helper --- @@ -193,12 +194,12 @@ function withInstance( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) }, }) } diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 8b29f6d1e3..3cf6774215 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -112,6 +112,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") test("first connect to OAuth server shows needs_auth instead of failed", async () => { @@ -132,7 +133,7 @@ test("first connect to OAuth server shows needs_auth instead of failed", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await Effect.runPromise( @@ -162,7 +163,7 @@ test("state() generates a new state when none is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -203,7 +204,7 @@ test("state() returns existing state when one is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -252,7 +253,7 @@ test("authenticate() stores a connected client when auth completes without redir }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise( diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 3a6df02a15..20cb90a18e 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -106,6 +106,7 @@ const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -127,7 +128,7 @@ test("BrowserOpenFailed event is published when open() throws", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = true @@ -183,7 +184,7 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false @@ -237,7 +238,7 @@ test("open() is called with the authorization URL", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index d4f9192c76..64b93bb8bc 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { Instance } from "../src/project/instance" +import { WithInstance } from "../src/project/with-instance" import { disposeAllInstances, tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" @@ -158,7 +159,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -183,7 +184,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -208,7 +209,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -235,7 +236,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -273,7 +274,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -304,7 +305,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index c615e55e5e..1c3d6fc563 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,8 +6,15 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" -import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { + disposeAllInstances, + provideInstance, + provideTmpdirInstance, + reloadTestInstance, + tmpdirScoped, +} from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -1000,7 +1007,7 @@ it.live("pending permission rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) @@ -1024,7 +1031,7 @@ it.live("pending permission rejects on instance reload", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1118,7 +1125,7 @@ it.live("ask - abort should clear pending request", () => const pending = yield* waitForPending(1).pipe(run) expect(pending).toHaveLength(1) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 4bee985796..c77c0ca1c0 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,11 +1,40 @@ import { describe, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect } from "effect" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { pathToFileURL } from "url" +import { Effect, Layer } from "effect" +import { provideTestInstance, tmpdir } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "../../src/provider/schema" +import { Plugin } from "@/plugin" +import { Auth } from "@/auth" +import { Bus } from "@/bus" +import { TestConfig } from "../fixture/config" + +function layer(directory: string, plugins: string[]) { + return ProviderAuth.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ + spec: plugin, + source: path.join(directory, "opencode.json"), + scope: "local" as const, + })), + }), + directories: () => Effect.succeed([directory]), + }), + ), + ), + ), + ) +} describe("plugin.auth-override", () => { test("user plugin overrides built-in github-copilot auth", async () => { @@ -37,30 +66,32 @@ describe("plugin.auth-override", () => { await using plain = await tmpdir() - const methods = await Instance.provide({ - directory: tmp.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), - ) - }, - }) - - const plainMethods = await Instance.provide({ - directory: plain.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), - ) - }, - }) + const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href + const [methods, plainMethods] = await Promise.all([ + provideTestInstance({ + directory: tmp.path, + fn: async () => { + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))), + ) + }, + }), + provideTestInstance({ + directory: plain.path, + fn: async () => { + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))), + ) + }, + }), + ]) const copilot = methods[ProviderID.make("github-copilot")] expect(copilot).toBeDefined() expect(copilot.length).toBe(1) expect(copilot[0].label).toBe("Test Override Auth") expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") - }, 30000) // Increased timeout for plugin installation + }, 30000) }) const file = path.join(import.meta.dir, "../../src/plugin/index.ts") diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 6cf9dbe47e..384df3ddf6 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,9 +1,9 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.KILO_DISABLE_DEFAULT_PLUGINS @@ -12,8 +12,9 @@ process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { Instance } = await import("../../src/project/instance") +const { Bus } = await import("../../src/bus") const { Npm } = await import("@opencode-ai/core/npm") +const { TestConfig } = await import("../fixture/config") afterAll(() => { if (disableDefault === undefined) { @@ -28,14 +29,31 @@ afterEach(async () => { }) async function load(dir: string) { - return Instance.provide({ - directory: dir, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.list() - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) + const source = path.join(dir, "opencode.json") + const config = (await Bun.file(source).json()) as { plugin?: Array]> } + const plugins = config.plugin ?? [] + return Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.list() + }).pipe( + Effect.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), + }), + directories: () => Effect.succeed([dir]), + }), + ), + ), + ), + provideInstance(dir), + Effect.runPromise, + ) } describe("plugin.loader.shared", () => { diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 83ceca01c0..52434a6211 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -34,6 +34,7 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["KILO_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") +process.env["KILO_EXPERIMENTAL_EVENT_SYSTEM"] = "true" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills @@ -79,7 +80,7 @@ delete process.env["KILO_SERVER_USERNAME"] process.env["KILO_DB"] = ":memory:" // Now safe to import from src/ -const Log = await import("@opencode-ai/core/util/log") +const { Log } = await import("@opencode-ai/core/util/log") const { initProjectors } = await import("../src/server/projectors") void Log.init({ diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts new file mode 100644 index 0000000000..2bcaa909c1 --- /dev/null +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, test } from "bun:test" +import { Hono } from "hono" +import { existsSync } from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" +import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +// These regressions cover the legacy instance-loading paths fixed by PRs +// #25389 and #25449. The plugin config hook writes a marker file, and the test +// bodies deliberately avoid touching Plugin or config directly. The marker only +// exists if InstanceBootstrap ran at the instance boundary. + +afterEach(async () => { + await disposeAllInstances() +}) + +async function bootstrapFixture() { + return tmpdir({ + init: async (dir) => { + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + return marker + }, + }) +} + +test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { + await using tmp = await bootstrapFixture() + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => "ok", + }) + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("CLI bootstrap runs InstanceBootstrap before callback", async () => { + await using tmp = await bootstrapFixture() + + await cliBootstrap(tmp.path, async () => "ok") + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => { + await using tmp = await bootstrapFixture() + const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok")) + + const response = await app.request("/probe", { headers: { "x-kilo-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { + await using tmp = await bootstrapFixture() + + await InstanceRuntime.reloadInstance({ directory: tmp.path }) + + expect(existsSync(tmp.extra)).toBe(true) +}) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 852c58ef41..99b0f0666b 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -3,14 +3,25 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) +let bootstrapRun: Effect.Effect = Effect.void +const noopBootstrap = Layer.succeed( + InstanceBootstrap.Service, + InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }), +) + +const it = testEffect( + Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), +) afterEach(async () => { + bootstrapRun = Effect.void await disposeAllInstances() }) @@ -27,18 +38,16 @@ describe("InstanceStore", () => { }), ) - it.live("runs load init with InstanceRef provided", () => + it.live("runs bootstrap with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service let initializedDirectory: string | undefined - yield* store.load({ - directory: dir, - init: Effect.gen(function* () { - initializedDirectory = (yield* InstanceRef)?.directory - }), + bootstrapRun = Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory }) + yield* store.load({ directory: dir }) expect(initializedDirectory).toBe(dir) expect(() => Instance.current).toThrow() @@ -51,18 +60,11 @@ describe("InstanceStore", () => { const store = yield* InstanceStore.Service let initialized = 0 - const first = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), - }) - const second = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), + bootstrapRun = Effect.sync(() => { + initialized++ }) + const first = yield* store.load({ directory: dir }) + const second = yield* store.load({ directory: dir }) expect(second).toBe(first) expect(initialized).toBe(1) @@ -77,27 +79,19 @@ describe("InstanceStore", () => { const release = Promise.withResolvers() let initialized = 0 - const first = yield* store - .load({ - directory: dir, - init: Effect.promise(async () => { - initialized++ - started.resolve() - await release.promise - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.promise(async () => { + initialized++ + started.resolve() + await release.promise + }) + const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) yield* Effect.promise(() => started.promise) - const second = yield* store - .load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.sync(() => { + initialized++ + }) + const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) expect(initialized).toBe(1) release.resolve() @@ -114,27 +108,21 @@ describe("InstanceStore", () => { const store = yield* InstanceStore.Service let attempts = 0 - const failed = yield* store - .load({ - directory: dir, - init: Effect.sync(() => { - attempts++ - throw new Error("init failed") - }), - }) - .pipe( - Effect.as(false), - Effect.catchCause(() => Effect.succeed(true)), - ) + bootstrapRun = Effect.sync(() => { + attempts++ + throw new Error("init failed") + }) + const failed = yield* store.load({ directory: dir }).pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) expect(failed).toBe(true) - const ctx = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - attempts++ - }), + bootstrapRun = Effect.sync(() => { + attempts++ }) + const ctx = yield* store.load({ directory: dir }) expect(ctx.directory).toBe(dir) expect(attempts).toBe(2) @@ -168,15 +156,11 @@ describe("InstanceStore", () => { yield* Effect.addFinalizer(() => Effect.sync(off)) const first = yield* store.load({ directory: dir }) - const reload = yield* store - .reload({ - directory: dir, - init: Effect.promise(async () => { - reloading.resolve() - await releaseReload.promise - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.promise(async () => { + reloading.resolve() + await releaseReload.promise + }) + const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped) yield* Effect.promise(() => reloading.promise) const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) @@ -237,12 +221,12 @@ describe("InstanceStore", () => { }), ) - it.live("keeps Instance.provide as the legacy ALS wrapper", () => + it.live("provides legacy Promise callers with instance ALS", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const directory = yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: dir, fn: () => Instance.directory, }), @@ -252,22 +236,4 @@ describe("InstanceStore", () => { expect(() => Instance.current).toThrow() }), ) - - it.live("does not install legacy ALS around Effect init", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - - const directory = yield* Effect.promise(() => - Instance.provide({ - directory: dir, - init: Effect.sync(() => { - expect(() => Instance.current).toThrow() - }), - fn: () => Instance.directory, - }), - ) - - expect(directory).toBe(dir) - }), - ) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e69b8e6df2..9906b31645 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Bus } from "@/bus" import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" @@ -63,6 +64,7 @@ function mockGitFailure(failArg: string) { function projectLayerWithFailure(failArg: string) { return Project.layer.pipe( Layer.provide(mockGitFailure(failArg)), + Layer.provide(Bus.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), ) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 0d0e46fe48..53ff547ac1 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -7,6 +7,7 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { GlobalBus } from "../../src/bus/global" import { Vcs } from "@/project/vcs" @@ -18,7 +19,7 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe // --------------------------------------------------------------------------- async function withVcs(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( @@ -36,7 +37,7 @@ async function withVcs(directory: string, body: () => Promise) { } function withVcsOnly(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( @@ -233,6 +234,7 @@ describe("Vcs diff", () => { }), ]), ) + expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") }) }) @@ -258,6 +260,34 @@ describe("Vcs diff", () => { }) }) + test("diff('git') keeps batched patches aligned for type changes", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet() + await fs.unlink(path.join(tmp.path, "a.txt")) + await fs.symlink("target", path.join(tmp.path, "a.txt")) + await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const a = diff.find((item) => item.file === "a.txt") + const b = diff.find((item) => item.file === "b.txt") + + expect(a?.patch).toContain("deleted file mode") + expect(a?.patch).toContain("new file mode") + expect(b?.patch).toContain("+new") + }) + }) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 806c47615b..b191a3c952 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,7 +5,8 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -138,9 +139,10 @@ describe("Worktree", () => { expect(props.branch).toBe(info.branch) yield* Effect.promise(() => - InstanceStore.runtime.runPromise((s) => - s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), - ), + WithInstance.provide({ + directory: info.directory, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }), ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) @@ -162,9 +164,10 @@ describe("Worktree", () => { yield* Effect.promise(() => ready) yield* Effect.promise(() => - InstanceStore.runtime.runPromise((s) => - s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), - ), + WithInstance.provide({ + directory: info.directory, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }), ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) @@ -175,12 +178,13 @@ describe("Worktree", () => { }) describe("createFromInfo", () => { - wintest("creates and bootstraps git worktree", () => + wintest("creates git worktree and boots asynchronously", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { const svc = yield* Worktree.Service const info = yield* svc.makeWorktreeInfo("from-info-test") + const ready = waitReady() yield* svc.createFromInfo(info) const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) @@ -188,6 +192,7 @@ describe("Worktree", () => { const normalizedDir = info.directory.replace(/\\/g, "/") expect(normalizedList).toContain(normalizedDir) + yield* Effect.promise(() => ready) yield* svc.remove({ directory: info.directory }) }), { git: true }, diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 43b23dafad..c35a03d78b 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -5,6 +5,7 @@ import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { Env } from "../../src/env" import { Global } from "@opencode-ai/core/global" @@ -43,13 +44,11 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_REGION", "us-east-1") set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -68,13 +67,11 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_REGION", "eu-west-1") set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -123,14 +120,12 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") set("AWS_BEARER_TOKEN_BEDROCK", "") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -169,13 +164,11 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_PROFILE", "default") set("AWS_ACCESS_KEY_ID", "test-key-id") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -201,12 +194,10 @@ test("Bedrock: includes custom endpoint in options when specified", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( @@ -234,15 +225,13 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -277,12 +266,10 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // The model should exist with the us. prefix @@ -314,12 +301,10 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -350,12 +335,10 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -386,12 +369,10 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // Non-prefixed model should still be registered diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 84478a34c4..8bb3b96347 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -9,6 +9,7 @@ export {} // import { ProviderID, ModelID } from "../../src/provider/schema" // import { tmpdir } from "../fixture/fixture" // import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" // import { Global } from "@opencode-ai/core/global" @@ -25,7 +26,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-gitlab-token") @@ -56,7 +57,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -95,7 +96,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -130,7 +131,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -162,7 +163,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") @@ -193,7 +194,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "env-token") @@ -216,7 +217,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -252,7 +253,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -277,7 +278,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -301,7 +302,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -349,7 +350,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -372,7 +373,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -396,7 +397,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 86dd8b53e4..349b93e1bb 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -5,6 +5,7 @@ import path from "path" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" @@ -80,12 +81,10 @@ test("provider loaded from env variable", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders @@ -114,7 +113,7 @@ test("provider loaded from config with apiKey option", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -135,12 +134,10 @@ test("disabled_providers excludes provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -159,13 +156,11 @@ test("enabled_providers restricts to only listed providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -189,12 +184,10 @@ test("model whitelist filters models for provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -220,12 +213,10 @@ test("model blacklist excludes specific models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -255,12 +246,10 @@ test("custom model alias via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() @@ -301,7 +290,7 @@ test("custom provider with npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -358,7 +347,7 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -392,12 +381,10 @@ test("env variable takes precedence, config merges options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "env-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "env-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged @@ -418,12 +405,10 @@ test("getModel returns model for valid provider/model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") @@ -445,12 +430,10 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, }) @@ -467,7 +450,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() @@ -498,12 +481,10 @@ test("defaultModel returns first available model when no config set", async () = ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() @@ -523,12 +504,10 @@ test("defaultModel respects config model setting", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") @@ -565,7 +544,7 @@ test("provider with baseURL from config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -603,7 +582,7 @@ test("model cost defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -638,12 +617,10 @@ test("model options are merged from existing model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") @@ -667,12 +644,10 @@ test("provider removed when all models filtered out", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -690,12 +665,10 @@ test("closest finds model by partial match", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") @@ -715,7 +688,7 @@ test("closest returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await closest(ProviderID.make("nonexistent"), ["model"]) @@ -745,12 +718,10 @@ test("getModel uses realIdByKey for aliased models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() @@ -791,7 +762,7 @@ test("provider api field sets model api.url", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -831,7 +802,7 @@ test("explicit baseURL overrides api field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -860,12 +831,10 @@ test("model inherits properties from existing database model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") @@ -888,12 +857,10 @@ test("disabled_providers prevents loading even with env var", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-openai-key") const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -912,13 +879,11 @@ test("enabled_providers with empty array allows no providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(Object.keys(providers).length).toBe(0) }, @@ -942,12 +907,10 @@ test("whitelist and blacklist can be combined", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -984,7 +947,7 @@ test("model modalities default correctly", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1027,7 +990,7 @@ test("model with custom cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1051,12 +1014,10 @@ test("getSmallModel returns appropriate small model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") @@ -1076,12 +1037,10 @@ test("getSmallModel respects config small_model override", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") @@ -1124,13 +1083,11 @@ test("multiple providers can be configured simultaneously", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() @@ -1169,7 +1126,7 @@ test("provider with custom npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1203,12 +1160,10 @@ test("model alias name defaults to alias key when id differs", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, @@ -1243,12 +1198,10 @@ test("provider with multiple env var options only includes apiKey when single en ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("MULTI_ENV_KEY_1", "test-key") - }).pipe(Effect.asVoid), fn: async () => { + set("MULTI_ENV_KEY_1", "test-key") const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set @@ -1285,12 +1238,10 @@ test("provider with single env var includes apiKey automatically", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("SINGLE_ENV_KEY", "my-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("SINGLE_ENV_KEY", "my-api-key") const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() // Single env option should auto-set key @@ -1322,12 +1273,10 @@ test("model cost overrides existing cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) @@ -1372,7 +1321,7 @@ test("completely new provider not in database can be configured", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1401,14 +1350,12 @@ test("disabled_providers and enabled_providers interaction", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic") set("OPENAI_API_KEY", "test-openai") set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1446,7 +1393,7 @@ test("model with tool_call false", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1481,7 +1428,7 @@ test("model defaults tool_call to true when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1520,7 +1467,7 @@ test("model headers are preserved", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1559,13 +1506,11 @@ test("provider env fallback - second env var used if first missing", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { // Only set fallback, not primary set("FALLBACK_KEY", "fallback-api-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() @@ -1584,12 +1529,10 @@ test("getModel returns consistent results", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) @@ -1625,7 +1568,7 @@ test("provider name defaults to id when not in database", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1645,12 +1588,10 @@ test("ModelNotFoundError includes suggestions for typos", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here @@ -1673,12 +1614,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic expect(true).toBe(false) // Should not reach here @@ -1701,7 +1640,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const provider = await getProvider(ProviderID.make("nonexistent")) @@ -1721,12 +1660,10 @@ test("getProvider returns provider info", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") @@ -1745,12 +1682,10 @@ test("closest returns undefined when no partial match found", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }, @@ -1768,12 +1703,10 @@ test("closest checks multiple query terms in order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") // First term won't match, second will const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() @@ -1808,7 +1741,7 @@ test("model limit defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1840,12 +1773,10 @@ test("provider options are deeply merged", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1878,12 +1809,10 @@ test("custom model inherits npm package from models.dev provider config", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() @@ -1913,12 +1842,10 @@ test("custom model inherits api.url from models.dev provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENROUTER_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENROUTER_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() @@ -2046,12 +1973,10 @@ test("model variants are generated for reasoning models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2084,12 +2009,10 @@ test("model variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2127,12 +2050,10 @@ test("model variants can be customized via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2166,12 +2087,10 @@ test("disabled key is stripped from variant config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() @@ -2204,12 +2123,10 @@ test("all variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2242,12 +2159,10 @@ test("variant config merges with generated variants", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2280,12 +2195,10 @@ test("variants filtered in second pass for database models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() @@ -2329,7 +2242,7 @@ test("custom model with variants enabled and disabled", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -2384,12 +2297,10 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }).pipe(Effect.asVoid), fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") @@ -2429,12 +2340,10 @@ test("Google Vertex: supports OpenAI compatible models", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }).pipe(Effect.asVoid), fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] @@ -2455,14 +2364,12 @@ test("cloudflare-ai-gateway loads with env variables", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, @@ -2487,14 +2394,12 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ @@ -2542,7 +2447,7 @@ test("plugin config providers persist after instance dispose", async () => { }, }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => AppRuntime.runPromise( @@ -2559,7 +2464,7 @@ test("plugin config providers persist after instance dispose", async () => { await disposeAllInstances() - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => list(), }) @@ -2590,13 +2495,11 @@ test("plugin config enabled and disabled providers are honored", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -2616,7 +2519,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2639,7 +2542,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) @@ -2660,7 +2563,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2694,7 +2597,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }), ) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 9ef9741bad..662042b64c 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { tmpdir } from "../fixture/fixture" import { setTimeout as sleep } from "node:timers/promises" @@ -10,7 +11,7 @@ describe("pty", () => { test("does not leak output when websocket objects are reused", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -60,7 +61,7 @@ describe("pty", () => { test("does not leak output when Bun recycles websocket objects before re-connect", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -105,7 +106,7 @@ describe("pty", () => { test("treats in-place socket data mutation as the same connection", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 3e4d658355..8c5d804b73 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -3,6 +3,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { Bus } from "../../src/bus" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import type { PtyID } from "../../src/pty/schema" import { tmpdir } from "../fixture/fixture" @@ -27,7 +28,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -68,7 +69,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts index 7b8b4d67ca..00e965d25e 100644 --- a/packages/opencode/test/pty/pty-shell.test.ts +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { Shell } from "../../src/shell/shell" import { tmpdir } from "../fixture/fixture" @@ -17,7 +18,7 @@ describe("pty shell args", () => { "does not add login args to pwsh", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -47,7 +48,7 @@ describe("pty shell args", () => { "adds login args to bash", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -78,7 +79,7 @@ describe("pty configured shell", () => { await using dir = await tmpdir({ config: { shell: Shell.name(configured) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts new file mode 100644 index 0000000000..4886f250f9 --- /dev/null +++ b/packages/opencode/test/pty/ticket.test.ts @@ -0,0 +1,57 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { WorkspaceID } from "../../src/control-plane/schema" +import { PtyID } from "../../src/pty/schema" +import { PtyTicket } from "../../src/pty/ticket" +import { testEffect } from "../lib/effect" + +const it = testEffect(PtyTicket.layer) +const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5))) + +describe("PTY websocket tickets", () => { + it.live("consumes tickets once", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" } + const issued = yield* tickets.issue(scope) + + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true) + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different request", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" }) + + expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true) + }), + ) + + itExpiring.live("rejects tickets after the TTL elapses", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID }) + + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))) + + expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different workspace", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const workspaceID = WorkspaceID.ascending() + const issued = yield* tickets.issue({ ptyID, workspaceID }) + + expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true) + }), + ) +}) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 83968a6f8c..4e2c8ef9bb 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,40 +1,65 @@ -import { afterEach, test, expect } from "bun:test" +import { afterEach, expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" -import { AppRuntime } from "../../src/effect/app-runtime" +import { testEffect } from "../lib/effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -const ask = (input: { sessionID: SessionID; questions: ReadonlyArray; tool?: Question.Tool }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input))) +const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) +const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Question.Tool +}) { + const question = yield* Question.Service + return yield* question.ask(input) +}) -const reply = (input: { requestID: QuestionID; answers: ReadonlyArray }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input))) +const listEffect = Question.Service.use((svc) => svc.list()) -const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) +const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: { + requestID: QuestionID + answers: ReadonlyArray +}) { + const question = yield* Question.Service + yield* question.reply(input) +}) + +const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) { + const question = yield* Question.Service + yield* question.reject(id) +}) afterEach(async () => { await disposeAllInstances() }) /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ -async function rejectAll() { - const pending = await list() - for (const req of pending) { - await reject(req.id) - } -} +const rejectAll = Effect.gen(function* () { + yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true }) +}) -test("ask - returns pending promise", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ +const waitForPending = (count: number) => + Effect.gen(function* () { + for (let i = 0; i < 100; i++) { + const pending = yield* listEffect + if (pending.length === count) return pending + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`)) + }) + +it.instance( + "ask - remains pending until answered", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -46,19 +71,19 @@ test("ask - returns pending promise", async () => { ], }, ], - }) - expect(promise).toBeInstanceOf(Promise) - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) + }).pipe(Effect.forkScoped) -test("ask - adds to pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) + +it.instance( + "ask - adds to pending list", + () => + Effect.gen(function* () { const questions = [ { question: "What would you like to do?", @@ -70,27 +95,26 @@ test("ask - adds to pending list", async () => { }, ] - const promise = ask({ + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions, - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) expect(pending.length).toBe(1) expect(pending[0].questions).toEqual(questions) - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) // reply tests -test("reply - resolves the pending ask with answers", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { +it.instance( + "reply - resolves the pending ask with answers", + () => + Effect.gen(function* () { const questions = [ { question: "What would you like to do?", @@ -102,31 +126,29 @@ test("reply - resolves the pending ask with answers", async () => { }, ] - const promise = ask({ + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions, - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) const requestID = pending[0].id - await reply({ + yield* replyEffect({ requestID, answers: [["Option 1"]], }) - const answers = await promise - expect(answers).toEqual([["Option 1"]]) - }, - }) -}) + expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) + }), + { git: true }, +) -test("reply - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ +it.instance( + "reply - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -138,45 +160,40 @@ test("reply - removes from pending list", async () => { ], }, ], - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) expect(pending.length).toBe(1) - await reply({ + yield* replyEffect({ requestID: pending[0].id, answers: [["Option 1"]], }) - await promise + yield* Fiber.join(fiber) - const after = await list() + const after = yield* listEffect expect(after.length).toBe(0) - }, - }) -}) + }), + { git: true }, +) -test("reply - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reply({ - requestID: QuestionID.make("que_unknown"), - answers: [["Option 1"]], - }) - // Should not throw - }, - }) -}) +it.instance( + "reply - does nothing for unknown requestID", + () => + replyEffect({ + requestID: QuestionID.make("que_unknown"), + answers: [["Option 1"]], + }), + { git: true }, +) // reject tests -test("reject - throws RejectedError", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ +it.instance( + "reject - throws RejectedError", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -188,22 +205,23 @@ test("reject - throws RejectedError", async () => { ], }, ], - }) + }).pipe(Effect.forkScoped) - const pending = await list() - await reject(pending[0].id) + const pending = yield* waitForPending(1) + yield* rejectEffect(pending[0].id) - await expect(promise).rejects.toBeInstanceOf(Question.RejectedError) - }, - }) -}) + const exit = yield* Fiber.await(fiber) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") + }), + { git: true }, +) -test("reject - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ +it.instance( + "reject - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -215,38 +233,30 @@ test("reject - removes from pending list", async () => { ], }, ], - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) expect(pending.length).toBe(1) - await reject(pending[0].id) - promise.catch(() => {}) // Ignore rejection + yield* rejectEffect(pending[0].id) + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - const after = await list() + const after = yield* listEffect expect(after.length).toBe(0) - }, - }) -}) + }), + { git: true }, +) -test("reject - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reject(QuestionID.make("que_unknown")) - // Should not throw - }, - }) +it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { + git: true, }) // multiple questions tests -test("ask - handles multiple questions", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { +it.instance( + "ask - handles multiple questions", + () => + Effect.gen(function* () { const questions = [ { question: "What would you like to do?", @@ -266,32 +276,30 @@ test("ask - handles multiple questions", async () => { }, ] - const promise = ask({ + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions, - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) - await reply({ + yield* replyEffect({ requestID: pending[0].id, answers: [["Build"], ["Dev"]], }) - const answers = await promise - expect(answers).toEqual([["Build"], ["Dev"]]) - }, - }) -}) + expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) + }), + { git: true }, +) // list tests -test("list - returns all pending requests", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const p1 = ask({ +it.instance( + "list - returns all pending requests", + () => + Effect.gen(function* () { + const fiber1 = yield* askEffect({ sessionID: SessionID.make("ses_test1"), questions: [ { @@ -300,9 +308,9 @@ test("list - returns all pending requests", async () => { options: [{ label: "A", description: "A" }], }, ], - }) + }).pipe(Effect.forkScoped) - const p2 = ask({ + const fiber2 = yield* askEffect({ sessionID: SessionID.make("ses_test2"), questions: [ { @@ -311,155 +319,114 @@ test("list - returns all pending requests", async () => { options: [{ label: "B", description: "B" }], }, ], - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(2) expect(pending.length).toBe(2) - await rejectAll() - p1.catch(() => {}) - p2.catch(() => {}) - }, - }) -}) + yield* rejectAll + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), + { git: true }, +) -test("list - returns empty when no pending", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await list() +it.instance( + "list - returns empty when no pending", + () => + Effect.gen(function* () { + const pending = yield* listEffect expect(pending.length).toBe(0) - }, - }) -}) + }), + { git: true }, +) -test("questions stay isolated by directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) +it.live("questions stay isolated by directory", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped({ git: true }) + const two = yield* tmpdirScoped({ git: true }) - const p1 = Instance.provide({ - directory: one.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_one"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }), - }) + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_one"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(provideInstance(one), Effect.forkScoped) - const p2 = Instance.provide({ - directory: two.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_two"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }), - }) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_two"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(provideInstance(two), Effect.forkScoped) - const onePending = await Instance.provide({ - directory: one.path, - fn: () => list(), - }) - const twoPending = await Instance.provide({ - directory: two.path, - fn: () => list(), - }) + const onePending = yield* waitForPending(1).pipe(provideInstance(one)) + const twoPending = yield* waitForPending(1).pipe(provideInstance(two)) - expect(onePending.length).toBe(1) - expect(twoPending.length).toBe(1) - expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) - expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) + expect(onePending.length).toBe(1) + expect(twoPending.length).toBe(1) + expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) + expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) - await Instance.provide({ - directory: one.path, - fn: () => reject(onePending[0].id), - }) - await Instance.provide({ - directory: two.path, - fn: () => reject(twoPending[0].id), - }) + yield* rejectEffect(onePending[0].id).pipe(provideInstance(one)) + yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two)) - await p1.catch(() => {}) - await p2.catch(() => {}) -}) + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), +) -test("pending question rejects on instance dispose", async () => { - await using tmp = await tmpdir({ git: true }) +it.live("pending question rejects on instance dispose", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_dispose"), + questions: [ + { + question: "Dispose me?", + header: "Dispose", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_dispose"), - questions: [ - { - question: "Dispose me?", - header: "Dispose", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await InstanceStore.disposeInstance(Instance.current) - }, - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) - expect(await result).toBeInstanceOf(Question.RejectedError) -}) +it.live("pending question rejects on instance reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_reload"), + questions: [ + { + question: "Reload me?", + header: "Reload", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) -test("pending question rejects on instance reload", async () => { - await using tmp = await tmpdir({ git: true }) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_reload"), - questions: [ - { - question: "Reload me?", - header: "Reload", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await InstanceStore.reloadInstance({ directory: tmp.path }) - }, - }) - - expect(await result).toBeInstanceOf(Question.RejectedError) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) diff --git a/packages/opencode/test/server/auth.test.ts b/packages/opencode/test/server/auth.test.ts new file mode 100644 index 0000000000..bf7d403fbe --- /dev/null +++ b/packages/opencode/test/server/auth.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Option, Redacted } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "../../src/server/auth" + +const original = { + KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD, + KILO_SERVER_USERNAME: Flag.KILO_SERVER_USERNAME, +} + +afterEach(() => { + Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD + Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME +}) + +describe("ServerAuth", () => { + test("does not emit auth headers without a password", () => { + Flag.KILO_SERVER_PASSWORD = undefined + Flag.KILO_SERVER_USERNAME = "alice" + + expect(ServerAuth.header()).toBeUndefined() + expect(ServerAuth.headers()).toBeUndefined() + }) + + test("defaults to the opencode username", () => { + Flag.KILO_SERVER_PASSWORD = "secret" + Flag.KILO_SERVER_USERNAME = undefined + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }) + }) + + test("uses the configured username", () => { + Flag.KILO_SERVER_PASSWORD = "secret" + Flag.KILO_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`, + }) + }) + + test("prefers explicit credentials", () => { + Flag.KILO_SERVER_PASSWORD = "secret" + Flag.KILO_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({ + Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`, + }) + }) + + test("validates decoded credentials against effect config", () => { + const config = { password: Option.some("secret"), username: "alice" } + + expect(ServerAuth.required(config)).toBe(true) + expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true) + expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false) + }) +}) diff --git a/packages/opencode/test/server/global-bus.ts b/packages/opencode/test/server/global-bus.ts new file mode 100644 index 0000000000..c8d0f92191 --- /dev/null +++ b/packages/opencode/test/server/global-bus.ts @@ -0,0 +1,34 @@ +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Cause, Effect } from "effect" + +export function waitGlobalBusEvent(input: { + timeout?: number + message?: string + predicate: (event: GlobalEvent) => boolean +}) { + return Effect.callback((resume) => { + const cleanup = () => GlobalBus.off("event", handler) + + const handler = (event: GlobalEvent) => { + try { + if (!input.predicate(event)) return + cleanup() + resume(Effect.succeed(event)) + } catch (error) { + cleanup() + resume(Effect.fail(error)) + } + } + + GlobalBus.on("event", handler) + return Effect.sync(cleanup) + }).pipe( + Effect.timeout(input.timeout ?? 10_000), + Effect.mapError((error) => + Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error, + ), + ) +} + +export const waitGlobalBusEventPromise = (input: Parameters[0]) => + Effect.runPromise(waitGlobalBusEvent(input)) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index a5ab7b8f36..9368089511 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import z from "zod" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -28,11 +29,11 @@ describe("session.listGlobal", () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) - const firstSession = await Instance.provide({ + const firstSession = await WithInstance.provide({ directory: first.path, fn: async () => svc.create({ title: "first-session" }), }) - const secondSession = await Instance.provide({ + const secondSession = await WithInstance.provide({ directory: second.path, fn: async () => svc.create({ title: "second-session" }), }) @@ -58,12 +59,12 @@ describe("session.listGlobal", () => { test("excludes archived sessions by default", async () => { await using tmp = await tmpdir({ git: true }) - const archived = await Instance.provide({ + const archived = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "archived-session" }), }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }), }) @@ -82,12 +83,12 @@ describe("session.listGlobal", () => { test("supports cursor pagination", async () => { await using tmp = await tmpdir({ git: true }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index c3bab23ac7..850098926a 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -2,12 +2,9 @@ import { NodeHttpServer } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" -import { - Authorization, - ServerAuthConfig, - authorizationLayer, -} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi" +import { ServerAuth } from "../../src/server/auth" +import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" const Api = HttpApi.make("test-authorization").add( @@ -16,27 +13,34 @@ const Api = HttpApi.make("test-authorization").add( HttpApiEndpoint.get("probe", "/probe", { success: Schema.String, }), + HttpApiEndpoint.get("missing", "/missing", { + success: Schema.String, + error: HttpApiError.NotFound, + }), ) .middleware(Authorization), ) -const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok"))) +const handlers = HttpApiBuilder.group(Api, "test", (handlers) => + handlers + .handle("probe", () => Effect.succeed("ok")) + .handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))), +) const apiLayer = HttpRouter.serve( HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)), { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) -const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" }) -const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" }) -const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) +const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" }) const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer))) -const basic = (username: string, password: string) => - `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? "" const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") @@ -93,6 +97,35 @@ describe("HttpApi authorization middleware", () => { }), ) + itSecret.live("prefers auth token query credentials over basic auth", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get( + `/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`, + ).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute) + + expect(response.status).toBe(200) + }), + ) + + itSecret.live("preserves handler errors when basic auth succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get("/missing").pipe( + HttpClientRequest.setHeader("authorization", basic("opencode", "secret")), + HttpClient.execute, + ) + + expect(response.status).toBe(404) + }), + ) + + itSecret.live("preserves handler errors when auth token query succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) + + expect(response.status).toBe(404) + }), + ) + itSecret.live("rejects malformed auth token query credentials", () => Effect.gen(function* () { const response = yield* HttpClient.get("/probe?auth_token=not-base64") diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index aeb94e4b12..796dbc1b67 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -222,15 +222,22 @@ describe("HttpApi server", () => { }) test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { - const honoRoutes = openApiRouteKeys(await Server.openapi()) + const honoRoutes = openApiRouteKeys(await Server.openapiHono()) const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) - expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) + expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([ + "GET /api/session", + "GET /api/session/{sessionID}/context", + "GET /api/session/{sessionID}/message", + "POST /api/session/{sessionID}/compact", + "POST /api/session/{sessionID}/prompt", + "POST /api/session/{sessionID}/wait", + ]) }) test("matches generated OpenAPI route parameters", async () => { - const hono = openApiParameters(await Server.openapi()) + const hono = openApiParameters(await Server.openapiHono()) const effect = openApiParameters(effectOpenApi()) expect( @@ -241,7 +248,7 @@ describe("HttpApi server", () => { }) test("matches generated OpenAPI request body shape", async () => { - const hono = openApiRequestBodies(await Server.openapi()) + const hono = openApiRequestBodies(await Server.openapiHono()) const effect = openApiRequestBodies(effectOpenApi()) expect( diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 83a98be548..d06a2f2726 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,20 +17,9 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 3b83712745..0f5b87f946 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -27,6 +27,14 @@ async function readFirstChunk(response: Response) { return new TextDecoder().decode(result.value) } +async function readFirstEvent(response: Response) { + return JSON.parse((await readFirstChunk(response)).replace(/^data: /, "")) as { + id?: string + type: string + properties: Record + } +} + afterEach(async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() @@ -43,7 +51,7 @@ describe("event HttpApi bridge", () => { expect(response.headers.get("cache-control")).toBe("no-cache, no-transform") expect(response.headers.get("x-accel-buffering")).toBe("no") expect(response.headers.get("x-content-type-options")).toBe("nosniff") - expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n') + expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) test("matches legacy first event frame", async () => { @@ -52,6 +60,9 @@ describe("event HttpApi bridge", () => { const legacy = await app(false).request(EventPaths.event, { headers }) const effect = await app(true).request(EventPaths.event, { headers }) - expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy)) + const legacyEvent = await readFirstEvent(legacy) + const effectEvent = await readFirstEvent(effect) + expect(effectEvent.type).toBe(legacyEvent.type) + expect(effectEvent.properties).toEqual(legacyEvent.properties) }) }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 6a5b64d8dc..de4139324d 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" @@ -11,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -31,20 +32,9 @@ function createSession(input?: Session.CreateInput) { } async function waitReady(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for worktree.ready")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for worktree.ready", + predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory, }) } @@ -137,12 +127,12 @@ describe("experimental HttpApi", () => { test("serves global session list through Hono bridge", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index a15156060d..8e0a682908 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,6 +1,5 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { describe, expect } from "bun:test" import { Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -11,15 +10,15 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { waitGlobalBusEvent } from "./global-bus" import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( @@ -42,8 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, + InstanceLayer.layer, Project.defaultLayer, Workspace.defaultLayer, ), @@ -97,24 +95,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => Layer.build, ) -const waitDisposedEvent = Effect.promise( - () => - new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed") return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve({ directory: event.directory, workspace: event.workspace }) - } - - GlobalBus.on("event", onEvent) - }), -) +const waitDisposedEvent = waitGlobalBusEvent({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", +}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace }))) const serveDisposeProbe = () => HttpRouter.serve( diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts index 3db2b993ad..ff5502316d 100644 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,20 +17,9 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } @@ -117,13 +105,9 @@ describe("instance HttpApi", () => { test("serves instance dispose through Hono bridge", async () => { await using tmp = await tmpdir() - const disposed = new Promise((resolve) => { - const onEvent = (event: { directory?: string; payload: { type?: string } }) => { - if (event.payload.type !== "server.instance.disposed") return - GlobalBus.off("event", onEvent) - resolve(event.directory) - } - GlobalBus.on("event", onEvent) + const disposed = waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", }) const response = await app().request(InstancePaths.dispose, { @@ -133,6 +117,6 @@ describe("instance HttpApi", () => { expect(response.status).toBe(200) expect(await response.json()).toBe(true) - expect(await disposed).toBe(tmp.path) + expect((await disposed).directory).toBe(tmp.path) }) }) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts new file mode 100644 index 0000000000..a9960bb733 --- /dev/null +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -0,0 +1,319 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = { + KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI, + KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD, + KILO_SERVER_USERNAME: Flag.KILO_SERVER_USERNAME, + envPassword: process.env.KILO_SERVER_PASSWORD, + envUsername: process.env.KILO_SERVER_USERNAME, +} +const auth = { username: "opencode", password: "listen-secret" } +const testPty = process.platform === "win32" ? test.skip : test + +afterEach(async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI + Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD + Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME + if (original.envPassword === undefined) delete process.env.KILO_SERVER_PASSWORD + else process.env.KILO_SERVER_PASSWORD = original.envPassword + if (original.envUsername === undefined) delete process.env.KILO_SERVER_USERNAME + else process.env.KILO_SERVER_USERNAME = original.envUsername + await disposeAllInstances() + await resetDatabase() +}) + +async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.KILO_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" + Flag.KILO_SERVER_PASSWORD = auth.password + Flag.KILO_SERVER_USERNAME = auth.username + process.env.KILO_SERVER_PASSWORD = auth.password + process.env.KILO_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + +async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.KILO_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" + Flag.KILO_SERVER_PASSWORD = undefined + Flag.KILO_SERVER_USERNAME = auth.username + delete process.env.KILO_SERVER_PASSWORD + process.env.KILO_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + +function authorization() { + return `Basic ${btoa(`${auth.username}:${auth.password}`)}` +} + +function socketURL(listener: Awaited>, id: string, dir: string, ticket?: string) { + const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url) + url.protocol = "ws:" + url.searchParams.set("directory", dir) + url.searchParams.set("cursor", "-1") + if (ticket) url.searchParams.set("ticket", ticket) + return url +} + +async function requestTicket( + listener: Awaited>, + id: string, + dir: string, + options?: { ticketHeader?: boolean; origin?: string }, +) { + const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-kilo-directory": dir, + ...(options?.ticketHeader === false ? {} : { "x-kilo-ticket": "1" }), + ...(options?.origin ? { origin: options.origin } : {}), + }, + }) + + return response +} + +async function connectTicket(listener: Awaited>, id: string, dir: string) { + const response = await requestTicket(listener, id, dir) + expect(response.status).toBe(200) + return (await response.json()) as { ticket: string; expires_in: number } +} + +async function createCat(listener: Awaited>, dir: string) { + const response = await fetch(new URL(PtyPaths.create, listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-kilo-directory": dir, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }), + }) + expect(response.status).toBe(200) + return (await response.json()) as { id: string } +} + +async function openSocket(url: URL) { + const ws = new WebSocket(url) + ws.binaryType = "arraybuffer" + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true }) + }), + 5_000, + "timed out waiting for websocket open", + ) + return ws +} + +async function expectSocketRejected(url: URL, init?: { headers?: Record }) { + // Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that. + const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record }) => WebSocket + const ws = new Ctor(url, init) + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener( + "open", + () => { + ws.close(1000) + reject(new Error("websocket opened")) + }, + { once: true }, + ) + ws.addEventListener("error", () => resolve(), { once: true }) + ws.addEventListener("close", () => resolve(), { once: true }) + }), + 5_000, + "timed out waiting for websocket rejection", + ) +} + +function stop(listener: Awaited>, label: string) { + return withTimeout(listener.stop(true), 10_000, label) +} + +function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) { + const decoder = new TextDecoder() + let onMessage: ((event: MessageEvent) => void) | undefined + return withTimeout( + new Promise((resolve) => { + onMessage = (event: MessageEvent) => { + const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer) + if (!predicate(message)) return + resolve(message) + } + ws.addEventListener("message", onMessage) + }), + 5_000, + "timed out waiting for websocket message", + ).finally(() => { + if (onMessage) ws.removeEventListener("message", onMessage) + }) +} + +describe("HttpApi Server.listen", () => { + testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + let stopped = false + try { + const response = await fetch(new URL(PtyPaths.shells, listener.url), { + headers: { authorization: authorization(), "x-kilo-directory": tmp.path }, + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }), + ]), + ) + + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + expect(ticket.expires_in).toBeGreaterThan(0) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const closed = new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })) + + const message = waitForMessage(ws, (message) => message.includes("ping-listen")) + ws.send("ping-listen\n") + expect(await message).toContain("ping-listen") + + await stop(listener, "timed out waiting for listener.stop(true)") + stopped = true + await withTimeout(closed, 5_000, "timed out waiting for websocket close") + expect(ws.readyState).toBe(WebSocket.CLOSED) + + const restarted = await startListener() + try { + const nextInfo = await createCat(restarted, tmp.path) + const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path) + const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket)) + const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted")) + nextWs.send("ping-restarted\n") + expect(await nextMessage).toContain("ping-restarted") + nextWs.close(1000) + } finally { + await stop(restarted, "timed out waiting for restarted listener.stop(true)") + } + } finally { + if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined) + } + }) + + testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener("hono") + try { + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) + ws.send("ping-hono-ticket\n") + expect(await message).toContain("ping-hono-ticket") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) + } + }) + + testPty("rejects unsafe PTY ticket mint and connect requests", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403) + expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403) + + await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket")) + + const reusable = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket)) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket)) + ws.close(1000) + + const other = await createCat(listener, tmp.path) + const scoped = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket)) + + const crossOrigin = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), { + headers: { origin: "https://evil.example" }, + }) + } finally { + await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined) + } + }) + + // Regression for #25698 (Ope): the app's SDK call to + // `client.pty.connectToken({ ptyID })` originally omitted `directory`, so + // the server resolved the PTY in its own cwd context — where the project + // PTY isn't registered — and returned 404. The fix is to always pass + // `directory` from the app side; this test locks in two contracts: + // 1. Mint without directory cannot find a PTY registered in another dir. + // 2. Mint with the project directory succeeds; the resulting ticket + // consumes cleanly when the WS upgrade carries the same directory. + testPty("PTY connect token requires matching directory across mint and connect", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + // Mint without directory — server uses its own cwd, can't find the PTY. + const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), { + method: "POST", + headers: { authorization: authorization(), "x-kilo-ticket": "1" }, + }) + expect(ambiguous.status).toBe(404) + + // Mint with the project directory — succeeds, ticket binds to that scope. + const scoped = await fetch( + new URL( + `${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, + listener.url, + ), + { + method: "POST", + headers: { authorization: authorization(), "x-kilo-ticket": "1" }, + }, + ) + expect(scoped.status).toBe(200) + const mint = (await scoped.json()) as { ticket: string } + + // Same directory on the WS upgrade → consume succeeds. + const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket)) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined) + } + }) + + for (const backend of ["effect-httpapi", "hono"] as const) { + testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener(backend) + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) + ws.send(`ping-no-auth-${backend}\n`) + expect(await message).toContain(`ping-no-auth-${backend}`) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) + } +}) diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index 829f899605..d3ca4ae683 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => const passthroughAuthorization = Layer.succeed( Authorization, - Authorization.of({ - basic: (effect) => effect, - authToken: (effect) => effect, - }), + Authorization.of((effect) => effect), ) const passthroughInstanceContext = Layer.succeed( diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index a0fa72b10e..3937eef20e 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,7 +5,8 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -59,7 +60,7 @@ function withMcpProject(self: (dir: string) => Effect.Effect) ) yield* Effect.addFinalizer(() => Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts new file mode 100644 index 0000000000..d4116564e0 --- /dev/null +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { MessageID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.KILO_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app(experimental: boolean) { + Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function createSessionWithMessages(directory: string, count: number) { + return WithInstance.provide({ + directory, + fn: async () => { + const session = await runSession(Session.Service.use((svc) => svc.create({}))) + for (let i = 0; i < count; i++) { + await runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + }), + ) + } + return session.id + }, + }) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 1: Link header should reflect the request's actual Host header, +// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` +// which embeds localhost because request.url is path-only. Fix: use +// `HttpServerRequest.toURL(request)` which honors the Host header. +// ────────────────────────────────────────────────────────────────────────────── +describe("Link header host", () => { + test("HttpApi pagination Link header echoes request host", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sessionID = await createSessionWithMessages(tmp.path, 3) + + const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { + headers: { + host: "opencode.test:4096", + "x-kilo-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + const link = response.headers.get("link") + expect(link).not.toBeNull() + // Link should contain the request's Host, not "localhost". + expect(link).toContain("opencode.test") + expect(link).not.toContain("localhost") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. +// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a +// `NotFoundError` from the service surfaces as a defect → 500. Hono's +// equivalent maps to 404 via `errors.notFound`. +// +// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, +// fork, abort, init, deleteMessage, command, shell, revert, unrevert. +// +// FIXME: unskip when mapNotFound coverage is added (next PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("404 mapping for missing session", () => { + test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist/todo", { + headers: { "x-kilo-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 3: 404 response body shape should match Hono's NamedError +// envelope `{ name, data: { message } }`. HttpApi returns the typed-error +// shape `{ _tag }` instead. SDK consumers reading `error.data.message` +// see undefined. +// +// FIXME: unskip when error JSON shape policy is decided + applied (separate PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("Error JSON shape parity", () => { + test.todo("HttpApi 404 body matches NamedError shape", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist", { + headers: { "x-kilo-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + const body = (await response.json()) as { name?: string; data?: { message?: string } } + expect(body.name).toBe("NotFoundError") + expect(typeof body.data?.message).toBe("string") + }) +}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 84d699d43b..66f42da1e1 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,7 +3,8 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -91,7 +92,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 7424f4fd62..947b10e1b5 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -5,6 +5,7 @@ import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createKiloClient } from "@kilocode/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -226,7 +227,7 @@ function seedMessage(directory: string, sessionID: string) { const id = SessionID.make(sessionID) return call( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index b91e568666..a220b4d220 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -9,6 +9,7 @@ import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -16,7 +17,9 @@ import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" -import { SessionTable } from "@/session/session.sql" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" +import { SessionMessage } from "../../src/v2/session-message" +import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" @@ -44,7 +47,7 @@ function pathFor(path: string, params: Record) { function createSession(directory: string, input?: Session.CreateInput) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession(Session.Service.use((svc) => svc.create(input))), }), @@ -54,7 +57,7 @@ function createSession(directory: string, input?: Session.CreateInput) { function createTextMessage(directory: string, sessionID: SessionID, text: string) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession( @@ -202,6 +205,46 @@ describe("session HttpApi", () => { { headers }, ), ).toMatchObject({ info: { id: message.info.id } }) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const message = new SessionMessage.Assistant({ + id: SessionMessage.ID.create(), + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: DateTime.makeUnsafe(1) }, + content: [], + }) + Database.use((db) => + db + .insert(SessionMessageTable) + .values([ + { + id: message.id, + session_id: parent.id, + type: message.type, + time_created: 1, + data: { + time: { created: 1 }, + agent: message.agent, + model: message.model, + content: message.content, + } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run(), + ) + }, + }), + ) + + expect( + (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) + .items, + ).toMatchObject([{ type: "assistant" }]) }), ), ) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 5d6ad98f8b..f93b815053 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" @@ -38,7 +39,7 @@ describe("sync HttpApi", () => { const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" } const info = spyOn(Log.create({ service: "server.sync" }), "info") - const session = await Instance.provide({ + const session = await WithInstance.provide({ directory: tmp.path, fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))), }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index bb2f03db4c..d98456e6a1 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "../../src/bus/global" import { TuiEvent } from "../../src/cli/cmd/tui/event" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" @@ -12,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -23,14 +23,9 @@ function app(experimental = true) { } function nextCommandExecute() { - return new Promise((resolve) => { - const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => { - if (event.payload.type !== TuiEvent.CommandExecute.type) return - GlobalBus.off("event", listener) - resolve(event.payload.properties?.command) - } - GlobalBus.on("event", listener) - }) + return waitGlobalBusEventPromise({ + predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, + }).then((event) => event.payload.properties?.command) } async function expectTrue(path: string, headers: Record, body?: unknown) { @@ -51,7 +46,7 @@ afterEach(async () => { describe("tui HttpApi bridge", () => { test("documents legacy bad request responses", async () => { - const legacy = await Server.openapi() + const legacy = await Server.openapiHono() const effect = OpenApi.fromApi(TuiApi) for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 77013d0dd3..132b70b0cb 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -12,12 +12,10 @@ import { HttpServerResponse, } from "effect/unstable/http" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { - ServerAuthConfig, - authorizationRouterMiddleware, -} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { ServerAuth } from "../../src/server/auth" +import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { serveUIEffect } from "../../src/server/routes/ui" +import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" void Log.init({ print: false }) @@ -81,7 +79,7 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), ).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), Layer.provide([ AppFileSystem.defaultLayer, input?.client ?? httpClient(new Response("ui")), @@ -186,6 +184,82 @@ describe("HttpApi UI fallback", () => { expect(await response.text()).toBe("console.log('ok')") }) + // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was + // forwarded through the proxy while the proxy itself re-frames the body, + // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. + test("strips upstream transfer-encoding header from proxied assets", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), { + fs, + client, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + AppFileSystem.defaultLayer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("opencode", { + headers: { + "transfer-encoding": "chunked", + "content-type": "text/html", + }, + }), + ), + ), + ), + ), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("transfer-encoding")).toBeNull() + expect(await response.text()).toBe("opencode") + }) + + test("serves embedded UI assets when Bun can read them but access reports missing", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + let readPath: string | undefined + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* serveEmbeddedUIEffect( + "/assets/app.js", + { + ...fs, + existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"), + readFile: (path) => { + readPath = path + return path === "/$bunfs/root/assets/app.js" + ? Effect.succeed(new TextEncoder().encode("console.log('embedded')")) + : Effect.die(`unexpected embedded UI path: ${path}`) + }, + }, + { "assets/app.js": "/$bunfs/root/assets/app.js" }, + ) + }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), + ) + + expect(response.status).toBe(200) + expect(readPath).toBe("/$bunfs/root/assets/app.js") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(await response.text()).toBe("console.log('embedded')") + }) + test("keeps matched API routes ahead of the UI fallback", async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = true @@ -201,6 +275,7 @@ describe("HttpApi UI fallback", () => { const response = await uiApp({ password: "secret", username: "opencode" }).request("/") expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') }) test("accepts auth token for the web UI", async () => { @@ -228,6 +303,25 @@ describe("HttpApi UI fallback", () => { expect(response.status).toBe(200) }) + // Regression for #25698 (Ope): the browser fetches the PWA manifest and + // its icons via flows that don't carry app-managed credentials (the + // `` request is not under page-auth control), so the + // server returning 401 breaks PWA install. These specific public assets + // should bypass auth. + test("serves the PWA manifest without auth even when a server password is set", async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true + + for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { + const response = await uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("ok")), + }).request(path) + expect(response.status).not.toBe(401) + } + }) + test("allows web UI preflight without auth", async () => { Flag.KILO_EXPERIMENTAL_HTTPAPI = true diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 43f188e741..1ccc9bc8e6 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" @@ -31,7 +32,7 @@ afterEach(async () => { describe("session action routes", () => { test("abort route returns success", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 36fb30e38b..dda5a746fa 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -40,20 +41,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -73,20 +74,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -106,22 +107,22 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "current" }), }) - const deeper = await Instance.provide({ + const deeper = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), fn: async () => svc.create({ title: "deeper" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -146,14 +147,14 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "legacy-current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "legacy-sibling" }), }) @@ -175,7 +176,7 @@ describe("session.list", () => { test("filters root sessions", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root-session" }) @@ -192,7 +193,7 @@ describe("session.list", () => { test("filters by start time", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "new-session" }) @@ -206,7 +207,7 @@ describe("session.list", () => { test("filters by search term", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "unique-search-term-abc" }) @@ -223,7 +224,7 @@ describe("session.list", () => { test("respects limit parameter", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "session-1" }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index f59a02c317..a1feb1725f 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" @@ -76,7 +77,7 @@ describe("session messages endpoint", () => { test("returns cursor headers for older pages", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -105,7 +106,7 @@ describe("session messages endpoint", () => { test("keeps full-history responses when limit is omitted", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -126,7 +127,7 @@ describe("session messages endpoint", () => { test("rejects invalid cursors and missing sessions", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -147,7 +148,7 @@ describe("session messages endpoint", () => { test("does not truncate large legacy limit requests", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index b3230d4b8a..13edca1458 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -30,7 +31,7 @@ afterEach(async () => { describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -56,7 +57,7 @@ describe("tui.selectSession endpoint", () => { test("should return 404 when session does not exist", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -78,7 +79,7 @@ describe("tui.selectSession endpoint", () => { test("should return 400 when session ID format is invalid", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index 22c44a6dff..a921ae2774 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" -import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace" +import { + isLocalWorkspaceRoute, + getWorkspaceRouteSessionID, + workspaceProxyURL, +} from "../../src/server/shared/workspace-routing" import { SessionID } from "../../src/session/schema" describe("isLocalWorkspaceRoute", () => { diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts new file mode 100644 index 0000000000..efaad81f19 --- /dev/null +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -0,0 +1,148 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const stateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI, + KILO_EXPERIMENTAL_WORKSPACES: Flag.KILO_EXPERIMENTAL_WORKSPACES, + } + + Flag.KILO_EXPERIMENTAL_HTTPAPI = true + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI + Flag.KILO_EXPERIMENTAL_WORKSPACES = original.KILO_EXPERIMENTAL_WORKSPACES + await resetDatabase() + }), + ) + }), +) + +const it = testEffect(stateLayer) +type TestServer = ReturnType + +function serverScoped() { + return Effect.acquireRelease( + Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })), + (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore), + ) +} + +function request(server: TestServer, input: string, init?: RequestInit) { + return Effect.promise(() => + server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context), + ) +} + +function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { + return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label)) +} + +function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) { + return Effect.gen(function* () { + const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`) + expect(current.status).toBe(200) + const project = (yield* Effect.promise(() => current.json())) as { id: string } + const updated = yield* request( + input.server, + `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ commands: { start: input.command } }), + }, + ) + expect(updated.status).toBe(200) + }) +} + +describe("worktree endpoint reproduction", () => { + it.instance( + "direct HttpApi worktree create returns without waiting for boot", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + "direct worktree create", + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create does not hang", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create", + 8_000, + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + type: "worktree", + directory: expect.any(String), + }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create returns without waiting for project start command", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + yield* setProjectStartCommand({ + server, + directory: test.directory, + command: 'bun -e "setTimeout(() => {}, 2000)"', + }) + + const started = Date.now() + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create with project start command", + 6_000, + ) + + expect(response.status).toBe(200) + expect(Date.now() - started).toBeLessThan(1_500) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f35e044d7b..0d02d9918a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -10,6 +10,7 @@ import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -19,6 +20,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { SessionV2 } from "../../src/v2/session" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" @@ -26,6 +28,7 @@ import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { TestConfig } from "../fixture/config" void Log.init({ print: false }) @@ -208,7 +211,7 @@ function layer(result: "continue" | "compact") { function cfg(compaction?: Config.Info["compaction"]) { const base = Config.Info.zod.parse({}) - return Layer.mock(Config.Service)({ + return TestConfig.layer({ get: () => Effect.succeed({ ...base, compaction }), }) } @@ -595,6 +598,15 @@ describe("session.compaction.create", () => { auto: true, overflow: true, }) + + const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( + Effect.provide(SessionV2.defaultLayer), + ) + expect(v2.at(-1)).toMatchObject({ + type: "compaction", + reason: "auto", + summary: "", + }) }), ), ) @@ -791,7 +803,7 @@ describe("session.compaction.prune", () => { describe("session.compaction.process", () => { test("throws when parent is not a user message", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -821,7 +833,7 @@ describe("session.compaction.process", () => { test("publishes compacted event on continue", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -871,7 +883,7 @@ describe("session.compaction.process", () => { test("marks summary message as errored on compact result", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -909,7 +921,7 @@ describe("session.compaction.process", () => { test("adds synthetic continue prompt when auto is enabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -950,7 +962,7 @@ describe("session.compaction.process", () => { test("persists tail_start_id for retained recent turns", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -997,7 +1009,7 @@ describe("session.compaction.process", () => { test("shrinks retained tail to fit preserve token budget", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1046,7 +1058,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1095,7 +1107,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1154,7 +1166,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1217,7 +1229,7 @@ describe("session.compaction.process", () => { test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1260,7 +1272,7 @@ describe("session.compaction.process", () => { test("replays the prior user turn on overflow when earlier context exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1308,7 +1320,7 @@ describe("session.compaction.process", () => { test("falls back to overflow guidance when no replayable turn exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1368,7 +1380,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1442,7 +1454,7 @@ describe("session.compaction.process", () => { const ready = defer() await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1544,7 +1556,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1586,7 +1598,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1638,7 +1650,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1706,7 +1718,7 @@ describe("session.compaction.process", () => { stub.push(reply("summary one")) stub.push(reply("summary two")) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1778,7 +1790,7 @@ describe("session.compaction.process", () => { test("ignores previous summaries when sizing the retained tail", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index f800817594..3bb38c8786 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -5,8 +5,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Config } from "@/config/config" -import { emptyConsoleState } from "@/config/console-state" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" @@ -14,22 +12,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) -const configLayer = Layer.succeed( - Config.Service, - Config.Service.of({ - get: () => Effect.succeed({}), - getGlobal: () => Effect.succeed({}), - getConsoleState: () => Effect.succeed(emptyConsoleState), - update: () => Effect.void, - updateGlobal: (config) => Effect.succeed(config), - invalidate: () => Effect.void, - directories: () => Effect.succeed([]), - waitForDependencies: () => Effect.void, - }), -) +const configLayer = TestConfig.layer() const instructionLayer = (global: Partial) => Instruction.layer.pipe( diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index c648d62be8..7b96084832 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -6,6 +6,7 @@ import z from "zod" import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@/provider/models" @@ -338,7 +339,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -425,7 +426,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -515,7 +516,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -629,7 +630,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -745,7 +746,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -864,7 +865,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -982,7 +983,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) @@ -1223,7 +1224,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index afd24e7e1b..a7853be0b8 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -620,7 +620,7 @@ describe("session.message-v2.toModelMessage", () => { status: "completed", input: { cmd: "ls" }, output: "abcdefghij", - title: "Bash", + title: "Shell", metadata: {}, time: { start: 0, end: 1 }, }, @@ -740,9 +740,9 @@ describe("session.message-v2.toModelMessage", () => { "12179", "4575", "", - "", + "", "User aborted the command", - "", + "", ].join("\n") const input: MessageV2.WithParts[] = [ diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 17370bbe62..35b67f7a07 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -123,7 +124,7 @@ async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tai describe("MessageV2.page", () => { test("returns sync result", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -139,7 +140,7 @@ describe("MessageV2.page", () => { }) test("pages backward with opaque cursors", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -167,7 +168,7 @@ describe("MessageV2.page", () => { }) test("returns items in chronological order within a page", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -182,7 +183,7 @@ describe("MessageV2.page", () => { }) test("returns empty items for session with no messages", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -198,7 +199,7 @@ describe("MessageV2.page", () => { }) test("throws NotFoundError for non-existent session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const fake = "non-existent-session" as SessionID @@ -208,7 +209,7 @@ describe("MessageV2.page", () => { }) test("handles exact limit boundary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -225,7 +226,7 @@ describe("MessageV2.page", () => { }) test("limit of 1 returns single newest message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -242,7 +243,7 @@ describe("MessageV2.page", () => { }) test("hydrates multiple parts per message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -266,7 +267,7 @@ describe("MessageV2.page", () => { }) test("accepts cursors from fractional timestamps", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -284,7 +285,7 @@ describe("MessageV2.page", () => { }) test("messages with same timestamp are ordered by id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -304,7 +305,7 @@ describe("MessageV2.page", () => { }) test("does not return messages from other sessions", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -326,7 +327,7 @@ describe("MessageV2.page", () => { }) test("large limit returns all messages without cursor", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -346,7 +347,7 @@ describe("MessageV2.page", () => { describe("MessageV2.stream", () => { test("yields items newest first", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -361,7 +362,7 @@ describe("MessageV2.stream", () => { }) test("yields nothing for empty session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -375,7 +376,7 @@ describe("MessageV2.stream", () => { }) test("yields single message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -391,7 +392,7 @@ describe("MessageV2.stream", () => { }) test("hydrates parts for each yielded message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -409,7 +410,7 @@ describe("MessageV2.stream", () => { }) test("handles sets exceeding internal page size", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -426,7 +427,7 @@ describe("MessageV2.stream", () => { }) test("is a sync generator", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -447,7 +448,7 @@ describe("MessageV2.stream", () => { describe("MessageV2.parts", () => { test("returns parts for a message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -464,7 +465,7 @@ describe("MessageV2.parts", () => { }) test("returns empty array for message with no parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -479,7 +480,7 @@ describe("MessageV2.parts", () => { }) test("returns multiple parts in order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -512,7 +513,7 @@ describe("MessageV2.parts", () => { }) test("returns empty for non-existent message id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { await svc.create({}) @@ -523,7 +524,7 @@ describe("MessageV2.parts", () => { }) test("parts contain sessionID and messageID", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -541,7 +542,7 @@ describe("MessageV2.parts", () => { describe("MessageV2.get", () => { test("returns message with hydrated parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -560,7 +561,7 @@ describe("MessageV2.get", () => { }) test("throws NotFoundError for non-existent message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -575,7 +576,7 @@ describe("MessageV2.get", () => { }) test("scopes by session id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -593,7 +594,7 @@ describe("MessageV2.get", () => { }) test("returns message with multiple parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -616,7 +617,7 @@ describe("MessageV2.get", () => { }) test("returns assistant message with correct role", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -642,7 +643,7 @@ describe("MessageV2.get", () => { }) test("returns message with zero parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -660,7 +661,7 @@ describe("MessageV2.get", () => { describe("MessageV2.filterCompacted", () => { test("returns all messages when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -677,7 +678,7 @@ describe("MessageV2.filterCompacted", () => { }) test("stops at compaction boundary and returns chronological order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -721,7 +722,7 @@ describe("MessageV2.filterCompacted", () => { }) test("does not break on compaction part without matching summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -739,7 +740,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant with error even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -764,7 +765,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant without finish even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -785,7 +786,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains original tail when compaction stores tail_start_id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -841,7 +842,7 @@ describe("MessageV2.filterCompacted", () => { }) test("fork remaps compaction tail_start_id for filterCompacted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -907,7 +908,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains an assistant tail when compaction starts inside a turn", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -971,7 +972,7 @@ describe("MessageV2.filterCompacted", () => { }) test("prefers latest compaction boundary when repeated compactions exist", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1093,7 +1094,7 @@ describe("MessageV2.cursor", () => { describe("MessageV2 consistency", () => { test("page hydration matches get for each message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1112,7 +1113,7 @@ describe("MessageV2 consistency", () => { }) test("parts from get match standalone parts call", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1128,7 +1129,7 @@ describe("MessageV2 consistency", () => { }) test("stream collects same messages as exhaustive page iteration", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1155,7 +1156,7 @@ describe("MessageV2 consistency", () => { }) test("filterCompacted of full stream returns same as Array.from when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5330569401..a602c0c8d7 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -19,6 +19,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" +import { SessionMessageTable } from "../../src/session/session.sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -31,6 +32,7 @@ import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionV2 } from "../../src/v2/session" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" @@ -39,6 +41,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" @@ -371,6 +374,47 @@ it.live("loop calls LLM and returns assistant message", () => ), ) +it.live("prompt emits v2 prompted and synthetic events", () => + provideTmpdirServer( + Effect.fnUntraced(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "hello v2" }, + { + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,bm90ZSBjb250ZW50", + }, + ], + }) + + const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( + Effect.provide(SessionV2.layer), + ) + const row = Database.use((db) => + db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(), + ) + expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" }) + expect(typeof row?.data.time.created).toBe("number") + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }), + expect.objectContaining({ type: "synthetic", text: "note content" }), + ]), + ) + }), + { git: true, config: providerCfg }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts deleted file mode 100644 index defce40c14..0000000000 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ /dev/null @@ -1,916 +0,0 @@ -import { describe, expect, test } from "bun:test" -import * as DateTime from "effect/DateTime" -import * as FastCheck from "effect/testing/FastCheck" -import { SessionEntry } from "../../src/v2/session-entry" -import { SessionEntryStepper } from "../../src/v2/session-entry-stepper" -import { SessionEvent } from "../../src/v2/session-event" - -const time = (n: number) => DateTime.makeUnsafe(n) - -const word = FastCheck.string({ minLength: 1, maxLength: 8 }) -const text = FastCheck.string({ maxLength: 16 }) -const texts = FastCheck.array(text, { maxLength: 8 }) -const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 })) -const dict = FastCheck.dictionary(word, val, { maxKeys: 4 }) -const files = FastCheck.array( - word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })), - { maxLength: 2 }, -) - -function maybe(arb: FastCheck.Arbitrary) { - return FastCheck.oneof(FastCheck.constant(undefined), arb) -} - -function assistant() { - return new SessionEntry.Assistant({ - id: SessionEvent.ID.create(), - type: "assistant", - time: { created: time(0) }, - content: [], - retries: [], - }) -} - -function retryError(message: string) { - return new SessionEvent.RetryError({ - message, - isRetryable: true, - }) -} - -function retry(attempt: number, message: string, created: number) { - return new SessionEntry.AssistantRetry({ - attempt, - error: retryError(message), - time: { - created: time(created), - }, - }) -} - -function memoryState() { - const state: SessionEntryStepper.MemoryState = { - entries: [], - pending: [], - } - return state -} - -function active() { - const state: SessionEntryStepper.MemoryState = { - entries: [assistant()], - pending: [], - } - return state -} - -function run(events: SessionEvent.Event[], state = memoryState()) { - return events.reduce((state, event) => SessionEntryStepper.step(state, event), state) -} - -function last(state: SessionEntryStepper.MemoryState) { - const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant") - expect(entry?.type).toBe("assistant") - return entry?.type === "assistant" ? entry : undefined -} - -function texts_of(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text") -} - -function reasons(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning") -} - -function tools(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool") -} - -function tool(state: SessionEntryStepper.MemoryState, callID: string) { - return tools(state).find((x) => x.callID === callID) -} - -function retriesOf(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.retries ?? [] -} - -function adapterStore() { - return { - committed: [] as SessionEntry.Entry[], - deferred: [] as SessionEntry.Entry[], - } -} - -function adapterFor(store: ReturnType): SessionEntryStepper.Adapter { - const activeAssistantIndex = () => - store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - const getCurrentAssistant = () => { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = store.committed[index] - return assistant?.type === "assistant" ? assistant : undefined - } - - return { - getCurrentAssistant, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = store.committed[index] - if (current?.type !== "assistant") return - store.committed[index] = assistant - }, - appendEntry(entry) { - store.committed.push(entry) - }, - appendPending(entry) { - store.deferred.push(entry) - }, - finish() { - return store - }, - } -} - -describe("session-entry-stepper", () => { - describe("stepWith", () => { - test("reduces through a custom adapter", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }), - ) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(7), - }), - ) - - expect(store.deferred).toHaveLength(1) - expect(store.deferred[0]?.type).toBe("user") - expect(store.committed).toHaveLength(1) - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].content).toEqual([ - { type: "reasoning", text: "thought" }, - { type: "text", text: "world" }, - ]) - expect(store.committed[0].time.completed).toEqual(time(7)) - }) - - test("aggregates retry events onto the current assistant", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 2, - error: retryError("provider overloaded"), - timestamp: time(2), - }), - ) - - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) - }) - }) - - describe("memory", () => { - test("tracks and replaces the current assistant", () => { - const state = active() - const adapter = SessionEntryStepper.memory(state) - const current = adapter.getCurrentAssistant() - - expect(current?.type).toBe("assistant") - if (!current) return - - adapter.updateAssistant( - new SessionEntry.Assistant({ - ...current, - content: [new SessionEntry.AssistantText({ type: "text", text: "done" })], - time: { - ...current.time, - completed: time(1), - }, - }), - ) - - expect(adapter.getCurrentAssistant()).toBeUndefined() - expect(state.entries[0]?.type).toBe("assistant") - if (state.entries[0]?.type !== "assistant") return - - expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }]) - expect(state.entries[0].time.completed).toEqual(time(1)) - }) - - test("appends committed and pending entries", () => { - const state = memoryState() - const adapter = SessionEntryStepper.memory(state) - const committed = SessionEntry.User.fromEvent( - SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }), - ) - const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) })) - - adapter.appendEntry(committed) - adapter.appendPending(pending) - - expect(state.entries).toEqual([committed]) - expect(state.pending).toEqual([pending]) - }) - - test("stepWith through memory records reasoning", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }), - ) - - expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) - }) - - test("stepWith through memory records retries", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - - expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)]) - }) - }) - - describe("step", () => { - describe("seeded pending assistant", () => { - test("stores prompts in entries when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("user") - if (next.entries[0]?.type !== "user") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("stores prompts in pending when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - active(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.pending).toHaveLength(1) - expect(next.pending[0]?.type).toBe("user") - if (next.pending[0]?.type !== "user") return - expect(next.pending[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("accumulates text deltas on the latest text part", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = parts.reduce( - (state, part, i) => - SessionEntryStepper.step( - state, - SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }), - ), - SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), - ) - - expect(texts_of(next)).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("routes later text deltas to the latest text segment", () => { - FastCheck.assert( - FastCheck.property(texts, texts, (a, b) => { - const next = run( - [ - SessionEvent.Text.Started.create({ timestamp: time(1) }), - ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }), - ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })), - ], - active(), - ) - - expect(texts_of(next)).toEqual([ - { type: "text", text: a.join("") }, - { type: "text", text: b.join("") }, - ]) - }), - { numRuns: 50 }, - ) - }) - - test("reasoning.ended replaces buffered reasoning text", () => { - FastCheck.assert( - FastCheck.property(texts, text, (parts, end) => { - const next = run( - [ - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }), - ], - active(), - ) - - expect(reasons(next)).toEqual([ - { - type: "reasoning", - text: end, - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("tool.success completes the latest running tool", () => { - FastCheck.assert( - FastCheck.property( - word, - word, - dict, - maybe(text), - maybe(dict), - maybe(files), - texts, - (callID, title, input, output, metadata, attachments, parts) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - ...parts.map((x, i) => - SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), - ), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(parts.length + 2), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(parts.length + 3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("completed") - if (match?.state.status !== "completed") return - - expect(match.time.ran).toEqual(time(parts.length + 2)) - expect(match.state.input).toEqual(input) - expect(match.state.output).toBe(output ?? "") - expect(match.state.title).toBe(title) - expect(match.state.metadata).toEqual(metadata ?? {}) - expect(match.state.attachments).toEqual(attachments ?? []) - }, - ), - { numRuns: 50 }, - ) - }) - - test("tool.error completes the latest running tool with an error", () => { - FastCheck.assert( - FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Error.create({ - callID, - error, - metadata, - provider: { executed: true }, - timestamp: time(3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("error") - if (match?.state.status !== "error") return - - expect(match.time.ran).toEqual(time(2)) - expect(match.state.input).toEqual(input) - expect(match.state.error).toBe(error) - expect(match.state.metadata).toEqual(metadata ?? {}) - }), - { numRuns: 50 }, - ) - }) - - test("tool.success is ignored before tool.called promotes the tool to running", () => { - FastCheck.assert( - FastCheck.property(word, word, (callID, title) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Success.create({ - callID, - title, - provider: { executed: true }, - timestamp: time(2), - }), - ], - active(), - ) - const match = tool(next, callID) - expect(match?.state).toEqual({ - status: "pending", - input: "", - }) - }), - { numRuns: 50 }, - ) - }) - - test("step.ended copies completion fields onto the pending assistant", () => { - FastCheck.assert( - FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => { - const event = SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(n), - }) - const next = SessionEntryStepper.step(active(), event) - const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.time.completed).toEqual(event.timestamp) - expect(entry.cost).toBe(event.cost) - expect(entry.tokens).toEqual(event.tokens) - }), - { numRuns: 50 }, - ) - }) - }) - - describe("known reducer gaps", () => { - test("prompt appends immutably when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = memoryState() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.entries).toHaveLength(0) - expect(next.entries).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("prompt appends immutably when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = active() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.pending).toHaveLength(0) - expect(next.pending).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("step.started creates an assistant consumed by follow-up events", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = run([ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - const entry = last(next) - - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(entry.time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 100 }, - ) - }) - - test("replays prompt -> step -> text -> step.ended", () => { - FastCheck.assert( - FastCheck.property(word, texts, (body, parts) => { - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("user") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[1]?.type !== "assistant") return - - expect(next.entries[1].content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(next.entries[1].time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 50 }, - ) - }) - - test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { - FastCheck.assert( - FastCheck.property( - word, - texts, - text, - dict, - word, - maybe(text), - maybe(dict), - maybe(files), - (body, reason, end, input, title, output, metadata, attachments) => { - const callID = "call" - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), - ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(reason.length + 5), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(reason.length + 6), - }), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(reason.length + 7), - }), - ]) - - expect(next.entries.at(-1)?.type).toBe("assistant") - const entry = next.entries.at(-1) - if (entry?.type !== "assistant") return - - expect(entry.content).toHaveLength(2) - expect(entry.content[0]).toEqual({ - type: "reasoning", - text: end, - }) - expect(entry.content[1]?.type).toBe("tool") - if (entry.content[1]?.type !== "tool") return - expect(entry.content[1].state.status).toBe("completed") - expect(entry.time.completed).toEqual(time(reason.length + 7)) - }, - ), - { numRuns: 50 }, - ) - }) - - test("starting a new step completes the old assistant and appends a new active assistant", () => { - const next = run( - [ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - ], - active(), - ) - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("assistant") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return - - expect(next.entries[0].time.completed).toEqual(time(1)) - expect(next.entries[1].time.created).toEqual(time(1)) - expect(next.entries[1].time.completed).toBeUndefined() - }) - - test("handles sequential tools independently", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, (a, b, title, error) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title, - output: "done", - provider: { executed: true }, - timestamp: time(3), - }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "bash", - input: b, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Error.create({ - callID: "b", - error, - provider: { executed: true }, - timestamp: time(6), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - if (first?.state.status !== "completed") return - expect(first.state.input).toEqual(a) - expect(first.state.output).toBe("done") - expect(first.state.title).toBe(title) - - expect(second?.state.status).toBe("error") - if (second?.state.status !== "error") return - expect(second.state.input).toEqual(b) - expect(second.state.error).toBe(error) - }), - { numRuns: 50 }, - ) - }) - - test("routes tool events by callID when tool streams interleave", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), - SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), - SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "grep", - input: b, - provider: { executed: true }, - timestamp: time(6), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title: titleA, - output: "done-a", - provider: { executed: true }, - timestamp: time(7), - }), - SessionEvent.Tool.Success.create({ - callID: "b", - title: titleB, - output: "done-b", - provider: { executed: true }, - timestamp: time(8), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - expect(second?.state.status).toBe("completed") - if (first?.state.status !== "completed" || second?.state.status !== "completed") return - - expect(first.state.input).toEqual(a) - expect(second.state.input).toEqual(b) - expect(first.state.title).toBe(titleA) - expect(second.state.title).toBe(titleB) - }), - { numRuns: 50 }, - ) - }) - - test("records synthetic events", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("synthetic") - if (next.entries[0]?.type !== "synthetic") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("records compaction events", () => { - FastCheck.assert( - FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("compaction") - if (next.entries[0]?.type !== "compaction") return - expect(next.entries[0].auto).toBe(auto) - expect(next.entries[0].overflow).toBe(overflow) - }), - { numRuns: 50 }, - ) - }) - }) - }) -}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 99f20b44dc..bb69e459bc 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import { Bus } from "../../src/bus" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -34,7 +35,7 @@ function updatePart(part: T) { describe("session.created event", () => { test("should emit session.created event when session is created", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { let eventReceived = false @@ -63,7 +64,7 @@ describe("session.created event", () => { }) test("session.created event should be emitted before session.updated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const events: string[] = [] @@ -95,7 +96,7 @@ describe("step-finish token propagation via Bus event", () => { test( "non-zero tokens propagate through PartUpdated event", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const info = await create({}) @@ -166,7 +167,7 @@ describe("Session", () => { test("remove works without an instance", async () => { await using tmp = await tmpdir({ git: true }) - const info = await Instance.provide({ + const info = await WithInstance.provide({ directory: tmp.path, fn: () => create({ title: "remove-without-instance" }), }) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index bdf95caed5..da2ffb7937 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -5,6 +5,7 @@ import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" const projectRoot = path.join(__dirname, "../..") @@ -15,7 +16,7 @@ const hasApiKey = !!process.env.ANTHROPIC_API_KEY // Helper to run test within Instance context async function withInstance(fn: () => Promise): Promise { - return Instance.provide({ + return WithInstance.provide({ directory: projectRoot, fn, }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index c3216e1c58..99ddfe72d4 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" @@ -47,7 +48,7 @@ function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effe test("tracks deleted files correctly", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -62,7 +63,7 @@ test("tracks deleted files correctly", async () => { test("revert should remove new files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -86,7 +87,7 @@ test("revert should remove new files", async () => { test("revert in subdirectory", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -113,7 +114,7 @@ test("revert in subdirectory", async () => { test("multiple file operations", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -145,7 +146,7 @@ test("multiple file operations", async () => { test("empty directory handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -160,7 +161,7 @@ test("empty directory handling", async () => { test("binary file handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -184,7 +185,7 @@ test("binary file handling", async () => { test("symlink handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -199,7 +200,7 @@ test("symlink handling", async () => { test("file under size limit handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -214,7 +215,7 @@ test("file under size limit handling", async () => { test("large added files are skipped", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -231,7 +232,7 @@ test("large added files are skipped", async () => { test("nested directory revert", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -256,7 +257,7 @@ test("nested directory revert", async () => { test("special characters in filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -276,7 +277,7 @@ test("special characters in filenames", async () => { test("revert with empty patches", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should not crash with empty patches @@ -290,7 +291,7 @@ test("revert with empty patches", async () => { test("patch with invalid hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -309,7 +310,7 @@ test("patch with invalid hash", async () => { test("revert non-existent file", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -333,7 +334,7 @@ test("revert non-existent file", async () => { test("unicode filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -373,7 +374,7 @@ test("unicode filenames", async () => { test.skip("unicode filenames modification and restore", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const chineseFile = fwd(tmp.path, "文件.txt") @@ -402,7 +403,7 @@ test.skip("unicode filenames modification and restore", async () => { test("unicode filenames in subdirectories", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -428,7 +429,7 @@ test("unicode filenames in subdirectories", async () => { test("very long filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -455,7 +456,7 @@ test("very long filenames", async () => { test("hidden files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -475,7 +476,7 @@ test("hidden files", async () => { test("nested symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -495,7 +496,7 @@ test("nested symlinks", async () => { test("file permissions and ownership changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -516,7 +517,7 @@ test("file permissions and ownership changes", async () => { test("circular symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -547,7 +548,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -576,7 +577,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" test("gitignore changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -600,7 +601,7 @@ test("gitignore changes", async () => { test("files tracked in snapshot but now gitignored are filtered out", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // First, create a file and snapshot it @@ -634,7 +635,7 @@ test("files tracked in snapshot but now gitignored are filtered out", async () = test("gitignore updated between track calls filters from diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // a.txt is already committed from bootstrap - track it in snapshot @@ -669,7 +670,7 @@ test("gitignore updated between track calls filters from diff", async () => { test("git info exclude changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -695,7 +696,7 @@ test("git info exclude changes", async () => { test("git info exclude keeps global excludes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const global = `${tmp.path}/global.ignore` @@ -731,7 +732,7 @@ test("git info exclude keeps global excludes", async () => { test("concurrent file operations during patch", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -763,7 +764,7 @@ test("snapshot state isolation between projects", async () => { await using tmp1 = await bootstrap() await using tmp2 = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp1.path, fn: async () => { const before1 = await run(tmp1.path, (snapshot) => snapshot.track()) @@ -773,7 +774,7 @@ test("snapshot state isolation between projects", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp2.path, fn: async () => { const before2 = await run(tmp2.path, (snapshot) => snapshot.track()) @@ -793,14 +794,14 @@ test("patch detects changes in secondary worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -825,7 +826,7 @@ test("revert only removes files in invoking worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() @@ -834,7 +835,7 @@ test("revert only removes files in invoking worktree", async () => { const primaryFile = `${tmp.path}/worktree.txt` await Filesystem.write(primaryFile, "primary content") - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -869,14 +870,14 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -903,7 +904,7 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( test("track with no changes returns same hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const hash1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -922,7 +923,7 @@ test("track with no changes returns same hash", async () => { test("diff function with various changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -943,7 +944,7 @@ test("diff function with various changes", async () => { test("restore function", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -977,7 +978,7 @@ test("restore function", async () => { test("revert should not delete files that existed but were deleted in snapshot", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const snapshot1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1007,7 +1008,7 @@ test("revert should not delete files that existed but were deleted in snapshot", test("revert preserves file that existed in snapshot when deleted then recreated", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/existing.txt`, "original content") @@ -1044,7 +1045,7 @@ test("revert preserves file that existed in snapshot when deleted then recreated test("diffFull sets status based on git change type", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/grow.txt`, "one\n") @@ -1090,7 +1091,7 @@ test("diffFull sets status based on git change type", async () => { test("diffFull with new file additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1115,7 +1116,7 @@ test("diffFull with new file additions", async () => { test("diffFull with a large interleaved mixed diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) @@ -1178,7 +1179,7 @@ test("diffFull with a large interleaved mixed diff", async () => { test("diffFull preserves git diff order across batch boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) @@ -1204,7 +1205,7 @@ test("diffFull preserves git diff order across batch boundaries", async () => { test("diffFull with file modifications", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1230,7 +1231,7 @@ test("diffFull with file modifications", async () => { test("diffFull with file deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1255,7 +1256,7 @@ test("diffFull with file deletions", async () => { test("diffFull with multiple line additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1281,7 +1282,7 @@ test("diffFull with multiple line additions", async () => { test("diffFull with addition and deletion", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1313,7 +1314,7 @@ test("diffFull with addition and deletion", async () => { test("diffFull with multiple additions and deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1355,7 +1356,7 @@ test("diffFull with multiple additions and deletions", async () => { test("diffFull with no changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1372,7 +1373,7 @@ test("diffFull with no changes", async () => { test("diffFull with binary file changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1395,7 +1396,7 @@ test("diffFull with binary file changes", async () => { test("diffFull with whitespace changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2") @@ -1419,7 +1420,7 @@ test("diffFull with whitespace changes", async () => { test("revert with overlapping files across patches uses first patch hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Write initial content and snapshot @@ -1453,7 +1454,7 @@ test("revert with overlapping files across patches uses first patch hash", async test("revert preserves patch order when the same hash appears again", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await $`mkdir -p ${tmp.path}/foo`.quiet() @@ -1490,7 +1491,7 @@ test("revert preserves patch order when the same hash appears again", async () = test("revert handles large mixed batches across chunk boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 1557e1ff5d..12946d1227 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -124,7 +124,7 @@ describe("SyncEvent", () => { yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" }) yield* Effect.promise(() => received) expect(events).toHaveLength(1) - expect(events[0]).toEqual({ + expect(events[0]).toMatchObject({ type: "item.created", properties: { id: "evt_1", diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c4cccc6eb5..fd24b557b3 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs/promises" import { Effect, ManagedRuntime, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -97,7 +98,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const modifyPath = path.join(fixture.path, "modify.txt") @@ -149,7 +150,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -179,7 +180,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi.txt") @@ -199,7 +200,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const bom = String.fromCharCode(0xfeff) @@ -228,7 +229,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "insert_only.txt") @@ -247,7 +248,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "no_newline.txt") @@ -269,7 +270,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -292,7 +293,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -317,7 +318,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "duplicate.txt") @@ -335,7 +336,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" @@ -351,7 +352,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" @@ -365,7 +366,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const dirPath = path.join(fixture.path, "dir") @@ -382,7 +383,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" @@ -396,7 +397,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "modify.txt") @@ -414,7 +415,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = @@ -432,7 +433,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "tail.txt") @@ -450,7 +451,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "two_chunks.txt") @@ -468,7 +469,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi_ctx.txt") @@ -486,7 +487,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "eof_anchor.txt") @@ -508,7 +509,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `cat <<'EOF' @@ -529,7 +530,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `< { const target = path.join(fixture.path, "trailing_ws.txt") @@ -570,7 +571,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "leading_ws.txt") @@ -590,7 +591,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "unicode.txt") diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 2c381ad047..23ae0e9090 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -73,7 +74,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "newfile.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -102,7 +103,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -131,7 +132,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -156,7 +157,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "new.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -191,7 +192,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "existing.txt") await fs.writeFile(filepath, "old content here", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -220,7 +221,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -250,7 +251,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -275,7 +276,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -300,7 +301,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "actual content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -325,7 +326,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -352,7 +353,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "original", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -387,7 +388,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -413,7 +414,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -439,7 +440,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -464,7 +465,7 @@ describe("tool.edit", () => { const dirpath = path.join(tmp.path, "adir") await fs.mkdir(dirpath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -489,7 +490,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -558,7 +559,7 @@ describe("tool.edit", () => { }, }) - return await Instance.provide({ + return await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -702,7 +703,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index ea1d340ce8..5914918178 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,6 +3,7 @@ import path from "path" import { Effect } from "effect" import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -38,7 +39,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp", fn: async () => { await assertExternalDirectory(ctx) @@ -51,7 +52,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for paths inside Instance.directory", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) @@ -68,7 +69,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside/file.txt" const expected = glob(path.join(path.dirname(target), "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target) @@ -88,7 +89,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside" const expected = glob(path.join(target, "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target, { kind: "directory" }) @@ -104,7 +105,7 @@ describe("tool.assertExternalDirectory", () => { test("skips prompting when bypass=true", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) @@ -131,7 +132,7 @@ describe("tool.assertExternalDirectory", () => { .replaceAll("\\", "/") .toLowerCase() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, alt) @@ -152,7 +153,7 @@ describe("tool.assertExternalDirectory", () => { const root = path.parse(tmp.path).root const target = path.join(root, "boot.ini") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, target) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 028436d295..94f401afd8 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( @@ -33,49 +33,47 @@ const ctx = { } describe("tool.glob", () => { - it.live("matches files from a directory path", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) - yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const result = yield* glob.execute( + it.instance("matches files from a directory path", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n")) + yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const result = yield* glob.execute( + { + pattern: "*.ts", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(test.directory, "a.ts")) + expect(result.output).not.toContain(path.join(test.directory, "b.txt")) + }), + ) + + it.instance("rejects exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "a.ts") + yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const exit = yield* glob + .execute( { pattern: "*.ts", - path: dir, + path: file, }, ctx, ) - expect(result.metadata.count).toBe(1) - expect(result.output).toContain(path.join(dir, "a.ts")) - expect(result.output).not.toContain(path.join(dir, "b.txt")) - }), - ), - ) - - it.live("rejects exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "a.ts") - yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const exit = yield* glob - .execute( - { - pattern: "*.ts", - path: file, - }, - ctx, - ) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - const err = Cause.squash(exit.cause) - expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") - } - }), - ), + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") + } + }), ) }) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index c807d12812..4b0da7c698 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { provideInstance, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" @@ -54,61 +54,58 @@ describe("tool.grep", () => { }), ) - it.live("no matches returns correct output", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "xyznonexistentpatternxyz123", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBe(0) - expect(result.output).toBe("No files found") - }), - ), + it.instance("no matches returns correct output", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "xyznonexistentpatternxyz123", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBe(0) + expect(result.output).toBe("No files found") + }), ) - it.live("finds matches in tmp instance", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBeGreaterThan(0) - }), - ), + it.instance("finds matches in tmp instance", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBeGreaterThan(0) + }), ) - it.live("supports exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "test.txt") - yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line2", - path: file, - }, - ctx, - ) - expect(result.metadata.matches).toBe(1) - expect(result.output).toContain(file) - expect(result.output).toContain("Line 2: line2") - }), - ), + it.instance("supports exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "test.txt") + yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line2", + path: file, + }, + ctx, + ) + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(file) + expect(result.output).toContain("Line 2: line2") + }), ) }) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index bc42b0324b..9f6a0617ed 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -10,7 +10,6 @@ import { toJsonSchema } from "../../src/util/effect-zod" // byte-identical regardless of whether a tool has migrated from zod to Schema. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" -import { Parameters as Bash } from "../../src/tool/bash" import { Parameters as Edit } from "../../src/tool/edit" import { Parameters as Glob } from "../../src/tool/glob" import { Parameters as Grep } from "../../src/tool/grep" @@ -19,6 +18,7 @@ import { Parameters as Lsp } from "../../src/tool/lsp" import { Parameters as Plan } from "../../src/tool/plan" import { Parameters as Question } from "../../src/tool/question" import { Parameters as Read } from "../../src/tool/read" +import { Parameters as Shell } from "../../src/tool/shell" import { Parameters as Skill } from "../../src/tool/skill" import { Parameters as Task } from "../../src/tool/task" import { Parameters as Todo } from "../../src/tool/todo" @@ -35,7 +35,7 @@ const accepts = (schema: Schema.Decoder, input: unknown): boolean => describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) - test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot()) + test("bash", () => expect(toJsonSchema(Shell)).toMatchSnapshot()) test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot()) test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot()) test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot()) @@ -66,20 +66,20 @@ describe("tool parameters", () => { }) }) - describe("bash", () => { + describe("shell", () => { test("accepts minimum: command + description", () => { - expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description (required by zod)", () => { - expect(accepts(Bash, { command: "ls" })).toBe(false) + test("rejects missing description", () => { + expect(accepts(Shell, { command: "ls" })).toBe(false) }) test("rejects missing command", () => { - expect(accepts(Bash, { description: "list" })).toBe(false) + expect(accepts(Shell, { description: "list" })).toBe(false) }) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 662073a8c3..3f2cba8941 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest }) describe("tool.question", () => { - it.live("should successfully execute with valid question parameters", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite color?", - header: "Color", - options: [ - { label: "Red", description: "The color of passion" }, - { label: "Blue", description: "The color of sky" }, - ], - multiple: false, - }, - ] + it.instance("should successfully execute with valid question parameters", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite color?", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + multiple: false, + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Red"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Red"]] }) - const result = yield* Fiber.join(fiber) - expect(result.title).toBe("Asked 1 question") - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.title).toBe("Asked 1 question") + }), ) - it.live("should now pass with a header longer than 12 but less than 30 chars", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite animal?", - header: "This Header is Over 12", - options: [{ label: "Dog", description: "Man's best friend" }], - }, - ] + it.instance("should now pass with a header longer than 12 but less than 30 chars", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Over 12", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) - const result = yield* Fiber.join(fiber) - expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) + }), ) // intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 3fa61401e1..695d96ec2f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,7 +13,7 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -255,28 +255,28 @@ describe("tool.read env file permissions", () => { }) describe("tool.read truncation", () => { - it.live("truncates large file by bytes and sets truncated metadata", () => + it.instance("truncates large file by bytes and sets truncated metadata", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const base = yield* load(path.join(FIXTURES_DIR, "models-api.json")) const target = 60 * 1024 const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length)) - yield* put(path.join(dir, "large.json"), content) + yield* put(path.join(test.directory, "large.json"), content) - const result = yield* exec(dir, { filePath: path.join(dir, "large.json") }) + const result = yield* run({ filePath: path.join(test.directory, "large.json") }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Output capped at") expect(result.output).toContain("Use offset=") }), ) - it.live("truncates by line count when limit is specified", () => + it.instance("truncates by line count when limit is specified", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - yield* put(path.join(dir, "many-lines.txt"), lines) + yield* put(path.join(test.directory, "many-lines.txt"), lines) - const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 }) + const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Showing lines 1-10 of 100") expect(result.output).toContain("Use offset=11") @@ -286,12 +286,12 @@ describe("tool.read truncation", () => { }), ) - it.live("does not truncate small file", () => + it.instance("does not truncate small file", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* put(path.join(dir, "small.txt"), "hello world") + const test = yield* TestInstance + yield* put(path.join(test.directory, "small.txt"), "hello world") - const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") }) + const result = yield* run({ filePath: path.join(test.directory, "small.txt") }) expect(result.metadata.truncated).toBe(false) expect(result.output).toContain("End of file") }), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 32ae0d69cf..cc72080e7b 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -2,151 +2,187 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { Effect, Layer } from "effect" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Plugin } from "@/plugin" +import { Question } from "@/question" +import { Todo } from "@/session/todo" +import { Skill } from "@/skill" +import { Agent } from "@/agent/agent" +import { Session } from "@/session/session" +import { Provider } from "@/provider/provider" +import { LSP } from "@/lsp/lsp" +import { Instruction } from "@/session/instruction" +import { Bus } from "@/bus" +import { FetchHttpClient } from "effect/unstable/http" +import { Format } from "@/format" +import { Ripgrep } from "@/file/ripgrep" +import * as Truncate from "@/tool/truncate" +import { InstanceState } from "@/effect/instance-state" const node = CrossSpawnSpawner.defaultLayer +const configLayer = TestConfig.layer({ + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), +}) -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) +const registryLayer = ToolRegistry.layer.pipe( + Layer.provide(configLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Format.defaultLayer), + Layer.provide(node), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), +) + +const it = testEffect(Layer.mergeAll(registryLayer, node)) afterEach(async () => { await disposeAllInstances() }) describe("tool.registry", () => { - it.live("loads tools from .opencode/tool (singular)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tool = path.join(opencode, "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tool (singular)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tool = path.join(opencode, "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools from .opencode/tools (plural)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tools (plural)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools with external dependencies without crashing", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package.json"), - JSON.stringify({ - name: "custom-tools", - dependencies: { - "@kilocode/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package-lock.json"), - JSON.stringify({ - name: "custom-tools", - lockfileVersion: 3, - packages: { - "": { - dependencies: { - "@kilocode/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, + it.instance("loads tools with external dependencies without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@kilocode/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package-lock.json"), + JSON.stringify({ + name: "custom-tools", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "@kilocode/plugin": "^0.0.0", + cowsay: "^1.6.0", }, }, - }), - ), - ) + }, + }), + ), + ) - const cowsay = path.join(opencode, "node_modules", "cowsay") - yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "package.json"), - JSON.stringify({ - name: "cowsay", - type: "module", - exports: "./index.js", - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "index.js"), - ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "cowsay.ts"), - [ - "import { say } from 'cowsay'", - "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("cowsay") - }), - ), + const cowsay = path.join(opencode, "node_modules", "cowsay") + yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "package.json"), + JSON.stringify({ + name: "cowsay", + type: "module", + exports: "./index.js", + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "index.js"), + ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("cowsay") + }), ) }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/shell.test.ts similarity index 88% rename from packages/opencode/test/tool/bash.test.ts rename to packages/opencode/test/tool/shell.test.ts index dfff9a5eea..b2e907020b 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -4,8 +4,9 @@ import os from "os" import path from "path" import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" +import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" @@ -28,9 +29,11 @@ const runtime = ManagedRuntime.make( ) function initBash() { - return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) + return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) } +const initShell = initBash + const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -68,6 +71,7 @@ const shells = (() => { })() const PS = new Set(["pwsh", "powershell"]) const ps = shells.filter((item) => PS.has(item.label)) +const cmdShell = shells.find((item) => item.label === "cmd") const sh = () => Shell.name(Shell.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) @@ -135,12 +139,12 @@ const mustTruncate = (result: { ) } -describe("tool.bash", () => { +describe("tool.shell", () => { each("basic", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -160,7 +164,7 @@ describe("tool.bash", () => { await using tmp = await tmpdir({ config: { shell: "fish" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -184,13 +188,13 @@ describe("tool.bash", () => { }) }) -describe("tool.bash permissions", () => { +describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -210,10 +214,10 @@ describe("tool.bash permissions", () => { each("asks for bash permission with multiple commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -236,10 +240,10 @@ describe("tool.bash permissions", () => { test( `parses PowerShell conditionals for permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -261,11 +265,43 @@ describe("tool.bash permissions", () => { ) } + for (const item of ps) { + test( + `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Remove-Item -Recurse tmp", + description: "Remove a temp directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }, + }) + }), + ) + } + each("asks for external_directory permission for wildcard external paths", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" @@ -298,10 +334,10 @@ describe("tool.bash permissions", () => { await Bun.write(path.join(dir, "outside.txt"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] await Effect.runPromise( @@ -331,10 +367,10 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -361,10 +397,10 @@ describe("tool.bash permissions", () => { test( `asks for nested PowerShell command permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` await Effect.runPromise( @@ -393,10 +429,10 @@ describe("tool.bash permissions", () => { `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -423,10 +459,10 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -454,7 +490,7 @@ describe("tool.bash permissions", () => { `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -484,7 +520,7 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -518,10 +554,10 @@ describe("tool.bash permissions", () => { const prev = process.env[key] delete process.env[key] try { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") @@ -553,7 +589,7 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -582,7 +618,7 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -614,7 +650,7 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -646,7 +682,7 @@ describe("tool.bash permissions", () => { test( `treats Set-Location like cd for permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -677,10 +713,10 @@ describe("tool.bash permissions", () => { test( `does not add nested PowerShell expressions to permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -702,9 +738,36 @@ describe("tool.bash permissions", () => { } } + if (process.platform === "win32" && cmdShell) { + test( + "asks for external_directory permission for cmd file commands [cmd]", + withShell(cmdShell, async () => { + await WithInstance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, + description: "Read Windows ini with cmd", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*"))) + }, + }) + }), + ) + } + each("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -729,7 +792,7 @@ describe("tool.bash permissions", () => { each("asks for external_directory permission when workdir is outside project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -759,7 +822,7 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") await using outerTmp = await tmpdir() await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -795,7 +858,7 @@ describe("tool.bash permissions", () => { test( "uses Git Bash /tmp semantics for external workdir", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -827,7 +890,7 @@ describe("tool.bash permissions", () => { test( "uses Git Bash /tmp semantics for external file paths", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -864,7 +927,7 @@ describe("tool.bash permissions", () => { }, }) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -897,7 +960,7 @@ describe("tool.bash permissions", () => { await Bun.write(path.join(dir, "tmpfile"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -919,7 +982,7 @@ describe("tool.bash permissions", () => { each("includes always patterns for auto-approval", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -942,10 +1005,10 @@ describe("tool.bash permissions", () => { each("does not ask for bash permission when command is cd only", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -964,10 +1027,10 @@ describe("tool.bash permissions", () => { each("matches redirects in permission pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -987,7 +1050,7 @@ describe("tool.bash permissions", () => { each("always pattern has space before wildcard to not include different commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -1001,12 +1064,12 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash abort", () => { +describe("tool.shell abort", () => { test("preserves output when aborted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const controller = new AbortController() const collected: string[] = [] const res = await Effect.runPromise( @@ -1037,10 +1100,10 @@ describe("tool.bash abort", () => { }, 15_000) test("terminates command on timeout", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1052,17 +1115,17 @@ describe("tool.bash abort", () => { ), ) expect(result.output).toContain("started") - expect(result.output).toContain("bash tool terminated command after exceeding timeout") + expect(result.output).toContain("shell tool terminated command after exceeding timeout") expect(result.output).toContain("retry with a larger timeout value in milliseconds") }, }) }, 15_000) test.skipIf(process.platform === "win32")("captures stderr in output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1080,10 +1143,10 @@ describe("tool.bash abort", () => { }) test("returns non-zero exit code", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1099,7 +1162,7 @@ describe("tool.bash abort", () => { }) test("streams metadata updates progressively", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -1128,12 +1191,12 @@ describe("tool.bash abort", () => { }) }) -describe("tool.bash truncation", () => { +describe("tool.shell truncation", () => { test("truncates output exceeding line limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 500 const result = await Effect.runPromise( bash.execute( @@ -1152,10 +1215,10 @@ describe("tool.bash truncation", () => { }) test("truncates output exceeding byte limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const byteCount = Truncate.MAX_BYTES + 10000 const result = await Effect.runPromise( bash.execute( @@ -1174,10 +1237,10 @@ describe("tool.bash truncation", () => { }) test("does not truncate small output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1194,10 +1257,10 @@ describe("tool.bash truncation", () => { }) test("full output is saved to file when truncated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 100 const result = await Effect.runPromise( bash.execute( diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9a01f95cd1..e948a6dcb3 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -9,6 +9,7 @@ import { Filesystem } from "@/util/filesystem" import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" +import { TestConfig } from "../fixture/config" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") @@ -16,11 +17,7 @@ const ROOT = path.resolve(import.meta.dir, "..", "..") const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer)) const configuredLayer = (cfg: Config.Info) => - Layer.mergeAll( - Truncate.defaultLayer, - NodeFileSystem.layer, - Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }), - ) + Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, TestConfig.layer({ get: () => Effect.succeed(cfg) })) const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index dbde4ed5b7..6c7f6aba77 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" @@ -41,7 +42,7 @@ describe("tool.webfetch", () => { await withFetch( () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" }) @@ -69,7 +70,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "image/svg+xml; charset=UTF-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" }) @@ -89,7 +90,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "text/plain; charset=utf-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 4931d2a544..8bba52a4b2 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -58,66 +58,79 @@ const run = Effect.fn("WriteToolTest.run")(function* ( describe("tool.write", () => { describe("new file creation", () => { - it.live("writes content to new file", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "newfile.txt") - const result = yield* run({ filePath: filepath, content: "Hello, World!" }) + it.instance("writes content to new file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "newfile.txt") + const result = yield* run({ filePath: filepath, content: "Hello, World!" }) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(false) + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(false) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("Hello, World!") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("Hello, World!") + }), ) - it.live("creates parent directories if needed", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "nested", "deep", "file.txt") - yield* run({ filePath: filepath, content: "nested content" }) + it.instance("creates parent directories if needed", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "nested", "deep", "file.txt") + yield* run({ filePath: filepath, content: "nested content" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("nested content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("nested content") + }), ) - it.live("handles relative paths by resolving to instance directory", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* run({ filePath: "relative.txt", content: "relative content" }) + it.instance("handles relative paths by resolving to instance directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* run({ filePath: "relative.txt", content: "relative content" }) - const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8")) - expect(content).toBe("relative content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8")) + expect(content).toBe("relative content") + }), ) }) describe("existing file overwrite", () => { - it.live("overwrites existing file content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "existing.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new content" }) + it.instance("overwrites existing file content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new content" }) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(true) + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(true) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("new content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("new content") + }), ) - it.live("preserves BOM when overwriting existing files", () => - provideTmpdirInstance((dir) => + it.instance("preserves BOM when overwriting existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ) + + it.instance( + "restores BOM after formatter strips it", + () => Effect.gen(function* () { - const filepath = path.join(dir, "existing.cs") + const test = yield* TestInstance + const filepath = path.join(test.directory, "formatted.cs") const bom = String.fromCharCode(0xfeff) yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) @@ -127,165 +140,138 @@ describe("tool.write", () => { expect(content.charCodeAt(0)).toBe(0xfeff) expect(content.slice(1)).toBe("using Up;\n") }), - ), - ) - - it.live("restores BOM after formatter strips it", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "formatted.cs") - const bom = String.fromCharCode(0xfeff) - yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) - - yield* run({ filePath: filepath, content: "using Up;\n" }) - - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\n") - }), - { - config: { - formatter: { - stripbom: { - extensions: [".cs"], - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", - "$FILE", - ], - }, + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], }, }, }, - ), + }, ) - it.live("returns diff in metadata for existing files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "file.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new" }) + it.instance("returns diff in metadata for existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new" }) - expect(result.metadata).toHaveProperty("filepath", filepath) - expect(result.metadata).toHaveProperty("exists", true) - }), - ), + expect(result.metadata).toHaveProperty("filepath", filepath) + expect(result.metadata).toHaveProperty("exists", true) + }), ) }) describe("file permissions", () => { - it.live("sets file permissions when writing sensitive data", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "sensitive.json") - yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) + it.instance("sets file permissions when writing sensitive data", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "sensitive.json") + yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) - if (process.platform !== "win32") { - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.mode & 0o777).toBe(0o644) - } - }), - ), + if (process.platform !== "win32") { + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.mode & 0o777).toBe(0o644) + } + }), ) }) describe("content types", () => { - it.live("writes JSON content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "data.json") - const data = { key: "value", nested: { array: [1, 2, 3] } } - yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) + it.instance("writes JSON content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "data.json") + const data = { key: "value", nested: { array: [1, 2, 3] } } + yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(JSON.parse(content)).toEqual(data) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(JSON.parse(content)).toEqual(data) + }), ) - it.live("writes binary-safe content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "binary.bin") - const content = "Hello\x00World\x01\x02\x03" - yield* run({ filePath: filepath, content }) + it.instance("writes binary-safe content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "binary.bin") + const content = "Hello\x00World\x01\x02\x03" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) - it.live("writes empty content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "empty.txt") - yield* run({ filePath: filepath, content: "" }) + it.instance("writes empty content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "empty.txt") + yield* run({ filePath: filepath, content: "" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("") - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.size).toBe(0) - }), - ), + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.size).toBe(0) + }), ) - it.live("writes multi-line content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "multiline.txt") - const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") - yield* run({ filePath: filepath, content: lines }) + it.instance("writes multi-line content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "multiline.txt") + const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") + yield* run({ filePath: filepath, content: lines }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe(lines) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe(lines) + }), ) - it.live("handles different line endings", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "crlf.txt") - const content = "Line 1\r\nLine 2\r\nLine 3" - yield* run({ filePath: filepath, content }) + it.instance("handles different line endings", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "crlf.txt") + const content = "Line 1\r\nLine 2\r\nLine 3" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) }) describe("error handling", () => { - it.live("throws error when OS denies write access", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const readonlyPath = path.join(dir, "readonly.txt") - yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) - yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) - const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) - expect(exit._tag).toBe("Failure") - }), - ), + it.instance("throws error when OS denies write access", () => + Effect.gen(function* () { + const test = yield* TestInstance + const readonlyPath = path.join(test.directory, "readonly.txt") + yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) + yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) + const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), ) }) describe("title generation", () => { - it.live("returns relative path as title", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "src", "components", "Button.tsx") - yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) + it.instance("returns relative path as title", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "src", "components", "Button.tsx") + yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) - const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) - expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) - }), - ), + const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) + expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) + }), ) }) }) diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index e536f3c4ea..e7a02d6151 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -22,6 +22,19 @@ describe("util.error", () => { expect(data.code).toBe("E_BAD") }) + test("never returns bare {} for opaque object errors", () => { + // Plain empty object — what the SDK threw before we wrapped it. + expect(errorFormat({})).not.toBe("{}") + expect(errorFormat({})).toContain("no message") + + // Object with only non-enumerable own properties (JSON.stringify drops them). + class OpaqueError {} + const opaque = new OpaqueError() + Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false }) + expect(errorFormat(opaque)).not.toBe("{}") + expect(errorFormat(opaque)).toContain("OpaqueError") + }) + test("handles opaque throwables with custom toString", () => { const err = { toString() { diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts new file mode 100644 index 0000000000..128177167c --- /dev/null +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from "bun:test" +import * as DateTime from "effect/DateTime" +import { SessionID } from "../../src/session/schema" +import { EventV2 } from "../../src/v2/event" +import { SessionEvent } from "../../src/v2/session-event" +import { SessionMessageUpdater } from "../../src/v2/session-message-updater" + +test("step snapshots carry over to assistant messages", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + snapshot: "before", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + finish: "stop", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + snapshot: "after", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) + expect(state.messages[0].finish).toBe("stop") +}) + +test("text ended populates assistant text content", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "hello assistant", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }]) +}) + +test("tool completion stores completed timestamp", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const callID = "call" + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.input.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + callID, + name: "bash", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.called", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + callID, + tool: "bash", + input: { command: "pwd" }, + provider: { executed: true, metadata: { source: "provider" } }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.success", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + callID, + structured: {}, + content: [{ type: "text", text: "/tmp" }], + provider: { executed: true, metadata: { status: "done" } }, + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content[0]?.type).toBe("tool") + if (state.messages[0].content[0]?.type !== "tool") return + expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4)) + expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } }) +}) + +test("compaction events reduce to compaction message", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const id = EventV2.ID.create() + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id, + type: "session.next.compaction.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + reason: "auto", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "hello ", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "summary", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + text: "final summary", + include: "recent context", + }, + } satisfies SessionEvent.Event) + + expect(state.messages).toHaveLength(1) + expect(state.messages[0]).toMatchObject({ + id, + type: "compaction", + reason: "auto", + summary: "final summary", + include: "recent context", + time: { created: DateTime.makeUnsafe(1) }, + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 63e2639fd6..1f9272c9f6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.42", + "version": "7.2.52", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 0c6f7bd66f..6738480269 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.42", + "version": "7.2.52", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 5cfdb44b0c..dd6187e474 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.42", + "version": "7.2.52", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 01f028302c..ff7218a654 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,13 +9,15 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -const openapiSource = process.env.KILO_SDK_OPENAPI === "httpapi" ? "httpapi" : "hono" +const openapiSource = process.env.KILO_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") +// `bun dev generate` now derives the spec from the Effect HttpApi contract by +// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs. if (openapiSource === "httpapi") { - await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode) -} else { await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +} else { + await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode) } await createClient({ diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index cce766e495..de272ba2ae 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -84,5 +84,24 @@ export function createKiloClient(config?: Config & { directory?: string; experim return response }) + // The generated client falls back to throwing a literal `{}` when the server + // responds with an empty / unparseable error body, which surfaces as a bare + // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so + // downstream formatters get a useful message — but pass through any parsed + // JSON error body unchanged so existing consumers can still inspect fields. + client.interceptors.error.use((error, response, request) => { + const isEmpty = + error === undefined || + error === null || + error === "" || + (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) + if (!isEmpty) return error + const method = request?.method ?? "?" + const url = request?.url ?? "?" + if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) + const status = response.status + const statusText = response.statusText ? " " + response.statusText : "" + return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) + }) return new KiloClient({ client }) } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index daeab1795e..4bbe7d0a63 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -20,10 +20,10 @@ import type { ConfigUpdateErrors, ConfigUpdateResponses, EventSubscribeResponses, - EventTuiCommandExecute, - EventTuiPromptAppend, - EventTuiSessionSelect, - EventTuiToastShow, + EventTuiCommandExecute2, + EventTuiPromptAppend2, + EventTuiSessionSelect2, + EventTuiToastShow2, ExperimentalConsoleGetResponses, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, @@ -90,6 +90,7 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, + Prompt, ProviderAuthResponses, ProviderListResponses, ProviderOauthAuthorizeErrors, @@ -98,6 +99,8 @@ import type { ProviderOauthCallbackResponses, PtyConnectErrors, PtyConnectResponses, + PtyConnectTokenErrors, + PtyConnectTokenResponses, PtyCreateErrors, PtyCreateResponses, PtyGetErrors, @@ -126,6 +129,7 @@ import type { SessionDeleteMessageErrors, SessionDeleteMessageResponses, SessionDeleteResponses, + SessionDelivery, SessionDiffResponses, SessionForkResponses, SessionGetErrors, @@ -187,6 +191,14 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + V2SessionCompactResponses, + V2SessionContextResponses, + V2SessionListErrors, + V2SessionListResponses, + V2SessionMessagesErrors, + V2SessionMessagesResponses, + V2SessionPromptResponses, + V2SessionWaitResponses, VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, @@ -244,111 +256,6 @@ class HeyApiRegistry { } } -export class Config extends HeyApiClient { - /** - * Get global configuration - * - * Retrieve the current global OpenCode configuration settings and preferences. - */ - public get(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/config", - ...options, - }) - } - - /** - * Update global configuration - * - * Update global OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) - return (options?.client ?? this.client).patch({ - url: "/global/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Global extends HeyApiClient { - /** - * Get health - * - * Get health information about the OpenCode server. - */ - public health(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/health", - ...options, - }) - } - - /** - * Get global events - * - * Subscribe to global events from the OpenCode system using server-sent events. - */ - public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ - url: "/global/event", - ...options, - }) - } - - /** - * Dispose instance - * - * Clean up and dispose all OpenCode instances, releasing all resources. - */ - public dispose(options?: Options) { - return (options?.client ?? this.client).post({ - url: "/global/dispose", - ...options, - }) - } - - /** - * Upgrade opencode - * - * Upgrade opencode to the specified version or latest if not specified. - */ - public upgrade( - parameters?: { - target?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) - return (options?.client ?? this.client).post({ - url: "/global/upgrade", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - private _config?: Config - get config(): Config { - return (this._config ??= new Config({ client: this.client })) - } -} - export class Auth extends HeyApiClient { /** * Remove auth credentials @@ -512,6 +419,419 @@ export class App extends HeyApiClient { } } +export class Config extends HeyApiClient { + /** + * Get global configuration + * + * Retrieve the current global OpenCode configuration settings and preferences. + */ + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/config", + ...options, + }) + } + + /** + * Update global configuration + * + * Update global OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) + return (options?.client ?? this.client).patch({ + url: "/global/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Global extends HeyApiClient { + /** + * Get health + * + * Get health information about the OpenCode server. + */ + public health(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/health", + ...options, + }) + } + + /** + * Get global events + * + * Subscribe to global events from the OpenCode system using server-sent events. + */ + public event(options?: Options) { + return (options?.client ?? this.client).sse.get({ + url: "/global/event", + ...options, + }) + } + + /** + * Dispose instance + * + * Clean up and dispose all OpenCode instances, releasing all resources. + */ + public dispose(options?: Options) { + return (options?.client ?? this.client).post({ + url: "/global/dispose", + ...options, + }) + } + + /** + * Upgrade opencode + * + * Upgrade opencode to the specified version or latest if not specified. + */ + public upgrade( + parameters?: { + target?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) + return (options?.client ?? this.client).post({ + url: "/global/upgrade", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _config?: Config + get config(): Config { + return (this._config ??= new Config({ client: this.client })) + } +} + +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/event", + ...options, + ...params, + }) + } +} + +export class Config2 extends HeyApiClient { + /** + * Get configuration + * + * Retrieve the current OpenCode configuration settings and preferences. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config", + ...options, + ...params, + }) + } + + /** + * Update configuration + * + * Update OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + directory?: string + workspace?: string + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "config", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List config providers + * + * Get a list of all configured AI providers and their default models. + */ + public providers( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config/providers", + ...options, + ...params, + }) + } +} + +export class Console extends HeyApiClient { + /** + * Get active Console provider metadata + * + * Get the active Console org name and the set of provider IDs managed by that Console org. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/console", + ...options, + ...params, + }) + } + + /** + * List switchable Console orgs + * + * Get the available Console orgs across logged-in accounts, including the current active org. + */ + public listOrgs( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/console/orgs", + ...options, + ...params, + }) + } + + /** + * Switch active Console org + * + * Persist a new active Console account/org selection for the current local OpenCode state. + */ + public switchOrg( + parameters?: { + directory?: string + workspace?: string + accountID?: string + orgID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "accountID" }, + { in: "body", key: "orgID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/console/switch", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Session extends HeyApiClient { + /** + * List sessions + * + * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + */ + public list( + parameters?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "cursor" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, + { in: "query", key: "archived" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session", + ...options, + ...params, + }) + } +} + +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + export class Adapter extends HeyApiClient { /** * List workspace adapters @@ -737,11 +1057,474 @@ export class Workspace extends HeyApiClient { } } -export class Console extends HeyApiClient { +export class Experimental extends HeyApiClient { + private _console?: Console + get console(): Console { + return (this._console ??= new Console({ client: this.client })) + } + + private _session?: Session + get session(): Session { + return (this._session ??= new Session({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } + + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) + } +} + +export class Tool extends HeyApiClient { /** - * Get active Console provider metadata + * List tools * - * Get the active Console org name and the set of provider IDs managed by that Console org. + * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + */ + public list( + parameters: { + directory?: string + workspace?: string + provider: string + model: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "provider" }, + { in: "query", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool", + ...options, + ...params, + }) + } + + /** + * List tool IDs + * + * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. + */ + public ids( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool/ids", + ...options, + ...params, + }) + } +} + +export class Worktree extends HeyApiClient { + /** + * Remove worktree + * + * Remove a git worktree and delete its branch. + */ + public remove( + parameters?: { + directory?: string + workspace?: string + worktreeRemoveInput?: WorktreeRemoveInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeRemoveInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List worktrees + * + * List all sandbox worktrees for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/worktree", + ...options, + ...params, + }) + } + + /** + * Create worktree + * + * Create a new git worktree for the current project and run any configured startup scripts. + */ + public create( + parameters?: { + directory?: string + workspace?: string + worktreeCreateInput?: WorktreeCreateInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeCreateInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reset worktree + * + * Reset a worktree branch to the primary default branch. + */ + public reset( + parameters?: { + directory?: string + workspace?: string + worktreeResetInput?: WorktreeResetInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeResetInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree/reset", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Find extends HeyApiClient { + /** + * Find text + * + * Search for text patterns across files in the project using ripgrep. + */ + public text( + parameters: { + directory?: string + workspace?: string + pattern: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "pattern" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find", + ...options, + ...params, + }) + } + + /** + * Find files + * + * Search for files or directories by name or pattern in the project directory. + */ + public files( + parameters: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + { in: "query", key: "dirs" }, + { in: "query", key: "type" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/file", + ...options, + ...params, + }) + } + + /** + * Find symbols + * + * Search for workspace symbols like functions, classes, and variables using LSP. + */ + public symbols( + parameters: { + directory?: string + workspace?: string + query: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/symbol", + ...options, + ...params, + }) + } +} + +export class File extends HeyApiClient { + /** + * List files + * + * List files and directories in a specified path. + */ + public list( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file", + ...options, + ...params, + }) + } + + /** + * Read file + * + * Read the content of a specified file. + */ + public read( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/content", + ...options, + ...params, + }) + } + + /** + * Get file status + * + * Get the git status of all files in the project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/status", + ...options, + ...params, + }) + } +} + +export class Instance extends HeyApiClient { + /** + * Dispose instance + * + * Clean up and dispose the current OpenCode instance, releasing all resources. + */ + public dispose( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/instance/dispose", + ...options, + ...params, + }) + } +} + +export class Path extends HeyApiClient { + /** + * Get paths + * + * Retrieve the current working directory and related path information for the OpenCode instance. */ public get( parameters?: { @@ -761,19 +1544,21 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/console", + return (options?.client ?? this.client).get({ + url: "/path", ...options, ...params, }) } +} +export class Vcs extends HeyApiClient { /** - * List switchable Console orgs + * Get VCS info * - * Get the available Console orgs across logged-in accounts, including the current active org. + * Retrieve version control system (VCS) information for the current project, such as git branch. */ - public listOrgs( + public get( parameters?: { directory?: string workspace?: string @@ -791,24 +1576,23 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/console/orgs", + return (options?.client ?? this.client).get({ + url: "/vcs", ...options, ...params, }) } /** - * Switch active Console org + * Get VCS diff * - * Persist a new active Console account/org selection for the current local OpenCode state. + * Retrieve the current git diff for the working tree or against the default branch. */ - public switchOrg( - parameters?: { + public diff( + parameters: { directory?: string workspace?: string - accountID?: string - orgID?: string + mode: "git" | "branch" }, options?: Options, ) { @@ -819,14 +1603,209 @@ export class Console extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "accountID" }, - { in: "body", key: "orgID" }, + { in: "query", key: "mode" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/console/switch", + return (options?.client ?? this.client).get({ + url: "/vcs/diff", + ...options, + ...params, + }) + } +} + +export class Command extends HeyApiClient { + /** + * List commands + * + * Get a list of all available commands in the OpenCode system. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/command", + ...options, + ...params, + }) + } +} + +export class Lsp extends HeyApiClient { + /** + * Get LSP status + * + * Get LSP server status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/lsp", + ...options, + ...params, + }) + } +} + +export class Formatter extends HeyApiClient { + /** + * Get formatter status + * + * Get formatter status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/formatter", + ...options, + ...params, + }) + } +} + +export class Auth2 extends HeyApiClient { + /** + * Remove MCP OAuth + * + * Remove OAuth credentials for an MCP server. + */ + public remove( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Start MCP OAuth + * + * Start OAuth authentication flow for a Model Context Protocol (MCP) server. + */ + public start( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Complete MCP OAuth + * + * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. + */ + public callback( + parameters: { + name: string + directory?: string + workspace?: string + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth/callback", ...options, ...params, headers: { @@ -836,24 +1815,17 @@ export class Console extends HeyApiClient { }, }) } -} -export class Session extends HeyApiClient { /** - * List sessions + * Authenticate MCP OAuth * - * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + * Start OAuth flow and wait for callback (opens browser). */ - public list( - parameters?: { + public authenticate( + parameters: { + name: string directory?: string workspace?: string - roots?: boolean | "true" | "false" - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean | "true" | "false" }, options?: Options, ) { @@ -862,33 +1834,30 @@ export class Session extends HeyApiClient { [ { args: [ + { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "cursor" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, - { in: "query", key: "archived" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/session", - ...options, - ...params, - }) + return (options?.client ?? this.client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + ...params, + }, + ) } } -export class Resource extends HeyApiClient { +export class Mcp extends HeyApiClient { /** - * Get MCP resources + * Get MCP status * - * Get all available MCP resources from connected servers. Optionally filter by name. + * Get the status of all Model Context Protocol (MCP) servers. */ - public list( + public status( parameters?: { directory?: string workspace?: string @@ -906,33 +1875,115 @@ export class Resource extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", + return (options?.client ?? this.client).get({ + url: "/mcp", ...options, ...params, }) } -} -export class Experimental extends HeyApiClient { - private _workspace?: Workspace - get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) + /** + * Add MCP server + * + * Dynamically add a new Model Context Protocol (MCP) server to the system. + */ + public add( + parameters?: { + directory?: string + workspace?: string + name?: string + config?: McpLocalConfig | McpRemoteConfig + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) } - private _console?: Console - get console(): Console { - return (this._console ??= new Console({ client: this.client })) + /** + * Connect an MCP server. + */ + public connect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/connect", + ...options, + ...params, + }) } - private _session?: Session - get session(): Session { - return (this._session ??= new Session({ client: this.client })) + /** + * Disconnect an MCP server. + */ + public disconnect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/disconnect", + ...options, + ...params, + }) } - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) + private _auth?: Auth2 + get auth(): Auth2 { + return (this._auth ??= new Auth2({ client: this.client })) } } @@ -1296,6 +2347,38 @@ export class Pty extends HeyApiClient { }) } + /** + * Create PTY WebSocket token + * + * Create a short-lived ticket for opening a PTY WebSocket connection. + */ + public connectToken( + parameters: { + ptyID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } + /** * Connect to PTY session * @@ -1329,147 +2412,48 @@ export class Pty extends HeyApiClient { } } -export class Config2 extends HeyApiClient { +export class Question extends HeyApiClient { /** - * Get configuration + * List pending questions * - * Retrieve the current OpenCode configuration settings and preferences. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/config", - ...options, - ...params, - }) - } - - /** - * Update configuration - * - * Update OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - directory?: string - workspace?: string - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "config", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).patch({ - url: "/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List config providers - * - * Get a list of all configured AI providers and their default models. - */ - public providers( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/config/providers", - ...options, - ...params, - }) - } -} - -export class Tool extends HeyApiClient { - /** - * List tool IDs - * - * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. - */ - public ids( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool/ids", - ...options, - ...params, - }) - } - - /** - * List tools - * - * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + * Get all pending question requests across all sessions. */ public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/question", + ...options, + ...params, + }) + } + + /** + * Reply to question request + * + * Provide answers to a question request from the AI assistant. + */ + public reply( parameters: { + requestID: string directory?: string workspace?: string - provider: string - model: string + answers?: Array }, options?: Options, ) { @@ -1478,50 +2462,16 @@ export class Tool extends HeyApiClient { [ { args: [ + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "provider" }, - { in: "query", key: "model" }, + { in: "body", key: "answers" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool", - ...options, - ...params, - }) - } -} - -export class Worktree extends HeyApiClient { - /** - * Remove worktree - * - * Remove a git worktree and delete its branch. - */ - public remove( - parameters?: { - directory?: string - workspace?: string - worktreeRemoveInput?: WorktreeRemoveInput - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeRemoveInput", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reply", ...options, ...params, headers: { @@ -1533,9 +2483,43 @@ export class Worktree extends HeyApiClient { } /** - * List worktrees + * Reject question request * - * List all sandbox worktrees for the current project. + * Reject a question request from the AI assistant. + */ + public reject( + parameters: { + requestID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reject", + ...options, + ...params, + }) + } +} + +export class Permission extends HeyApiClient { + /** + * List pending permissions + * + * Get all pending permission requests across all sessions. */ public list( parameters?: { @@ -1555,23 +2539,25 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/worktree", + return (options?.client ?? this.client).get({ + url: "/permission", ...options, ...params, }) } /** - * Create worktree + * Respond to permission request * - * Create a new git worktree for the current project and run any configured startup scripts. + * Approve or deny a permission request from the AI assistant. */ - public create( - parameters?: { + public reply( + parameters: { + requestID: string directory?: string workspace?: string - worktreeCreateInput?: WorktreeCreateInput + reply?: "once" | "always" | "reject" + message?: string }, options?: Options, ) { @@ -1580,15 +2566,17 @@ export class Worktree extends HeyApiClient { [ { args: [ + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeCreateInput", map: "body" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/permission/{requestID}/reply", ...options, ...params, headers: { @@ -1600,15 +2588,153 @@ export class Worktree extends HeyApiClient { } /** - * Reset worktree + * Respond to permission * - * Reset a worktree branch to the primary default branch. + * Approve or deny a permission request from the AI assistant. + * + * @deprecated */ - public reset( + public respond( + parameters: { + sessionID: string + permissionID: string + directory?: string + workspace?: string + response?: "once" | "always" | "reject" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "permissionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "response" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/permissions/{permissionID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Oauth extends HeyApiClient { + /** + * Start OAuth authorization + * + * Start the OAuth authorization flow for a provider. + */ + public authorize( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + inputs?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "inputs" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/authorize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Handle OAuth callback + * + * Handle the OAuth callback from a provider after user authorization. + */ + public callback( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/callback", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Provider extends HeyApiClient { + /** + * List providers + * + * Get a list of all available AI providers, including both available and connected ones. + */ + public list( parameters?: { directory?: string workspace?: string - worktreeResetInput?: WorktreeResetInput }, options?: Options, ) { @@ -1619,22 +2745,51 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeResetInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree/reset", + return (options?.client ?? this.client).get({ + url: "/provider", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } + + /** + * Get provider auth methods + * + * Retrieve available authentication methods for all AI providers. + */ + public auth( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/provider/auth", + ...options, + ...params, + }) + } + + private _oauth?: Oauth + get oauth(): Oauth { + return (this._oauth ??= new Oauth({ client: this.client })) + } } export class Session2 extends HeyApiClient { @@ -1691,6 +2846,12 @@ export class Session2 extends HeyApiClient { workspace?: string parentID?: string title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } permission?: PermissionRuleset workspaceID?: string }, @@ -1705,6 +2866,8 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "parentID" }, { in: "body", key: "title" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, { in: "body", key: "permission" }, { in: "body", key: "workspaceID" }, ], @@ -1926,184 +3089,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Initialize session - * - * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. - */ - public init( - parameters: { - sessionID: string - directory?: string - workspace?: string - modelID?: string - providerID?: string - messageID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "modelID" }, - { in: "body", key: "providerID" }, - { in: "body", key: "messageID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/init", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Fork session - * - * Create a new session by forking an existing session at a specific message point. - */ - public fork( - parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/fork", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Abort session - * - * Abort an active session and stop any ongoing AI processing or command execution. - */ - public abort( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/abort", - ...options, - ...params, - }) - } - - /** - * Unshare session - * - * Remove the shareable link for a session, making it private again. - */ - public unshare( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/share", - ...options, - ...params, - }) - } - - /** - * Share session - * - * Create a shareable link for a session, allowing others to view the conversation. - */ - public share( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/share", - ...options, - ...params, - }) - } - /** * Get message diff * @@ -2138,49 +3123,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Summarize session - * - * Generate a concise summary of the session using AI compaction to preserve key information. - */ - public summarize( - parameters: { - sessionID: string - directory?: string - workspace?: string - providerID?: string - modelID?: string - auto?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "providerID" }, - { in: "body", key: "modelID" }, - { in: "body", key: "auto" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/summarize", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - /** * Get session messages * @@ -2280,7 +3222,7 @@ export class Session2 extends HeyApiClient { /** * Delete message * - * Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message. + * Permanently delete a specific message and all of its parts from a session without reverting file changes. */ public deleteMessage( parameters: { @@ -2349,6 +3291,227 @@ export class Session2 extends HeyApiClient { }) } + /** + * Fork session + * + * Create a new session by forking an existing session at a specific message point. + */ + public fork( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/fork", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Abort session + * + * Abort an active session and stop any ongoing AI processing or command execution. + */ + public abort( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/abort", + ...options, + ...params, + }) + } + + /** + * Initialize session + * + * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. + */ + public init( + parameters: { + sessionID: string + directory?: string + workspace?: string + modelID?: string + providerID?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "modelID" }, + { in: "body", key: "providerID" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/init", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Unshare session + * + * Remove the shareable link for a session, making it private again. + */ + public unshare( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Share session + * + * Create a shareable link for a session, allowing others to view the conversation. + */ + public share( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Summarize session + * + * Generate a concise summary of the session using AI compaction to preserve key information. + */ + public summarize( + parameters: { + sessionID: string + directory?: string + workspace?: string + providerID?: string + modelID?: string + auto?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "providerID" }, + { in: "body", key: "modelID" }, + { in: "body", key: "auto" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/summarize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send async message * @@ -2591,7 +3754,7 @@ export class Session2 extends HeyApiClient { export class Part extends HeyApiClient { /** - * Delete a part from a message + * Delete a part from a message. */ public delete( parameters: { @@ -2625,7 +3788,7 @@ export class Part extends HeyApiClient { } /** - * Update a part in a message + * Update a part in a message. */ public update( parameters: { @@ -2666,386 +3829,6 @@ export class Part extends HeyApiClient { } } -export class Permission extends HeyApiClient { - /** - * Respond to permission - * - * Approve or deny a permission request from the AI assistant. - * - * @deprecated - */ - public respond( - parameters: { - sessionID: string - permissionID: string - directory?: string - workspace?: string - response?: "once" | "always" | "reject" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "permissionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "response" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/permissions/{permissionID}", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Respond to permission request - * - * Approve or deny a permission request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - workspace?: string - reply?: "once" | "always" | "reject" - message?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "reply" }, - { in: "body", key: "message" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/permission/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List pending permissions - * - * Get all pending permission requests across all sessions. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/permission", - ...options, - ...params, - }) - } -} - -export class Question extends HeyApiClient { - /** - * List pending questions - * - * Get all pending question requests across all sessions. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/question", - ...options, - ...params, - }) - } - - /** - * Reply to question request - * - * Provide answers to a question request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - workspace?: string - answers?: Array - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "answers" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Reject question request - * - * Reject a question request from the AI assistant. - */ - public reject( - parameters: { - requestID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reject", - ...options, - ...params, - }) - } -} - -export class Oauth extends HeyApiClient { - /** - * OAuth authorize - * - * Initiate OAuth authorization for a specific AI provider to get an authorization URL. - */ - public authorize( - parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - inputs?: { - [key: string]: string - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "inputs" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ProviderOauthAuthorizeResponses, - ProviderOauthAuthorizeErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/authorize", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * OAuth callback - * - * Handle the OAuth callback from a provider after user authorization. - */ - public callback( - parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ProviderOauthCallbackResponses, - ProviderOauthCallbackErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/callback", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Provider extends HeyApiClient { - /** - * List providers - * - * Get a list of all available AI providers, including both available and connected ones. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/provider", - ...options, - ...params, - }) - } - - /** - * Get provider auth methods - * - * Retrieve available authentication methods for all AI providers. - */ - public auth( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/provider/auth", - ...options, - ...params, - }) - } - - private _oauth?: Oauth - get oauth(): Oauth { - return (this._oauth ??= new Oauth({ client: this.client })) - } -} - export class History extends HeyApiClient { /** * List sync events @@ -3179,181 +3962,13 @@ export class Sync extends HeyApiClient { } } -export class Find extends HeyApiClient { +export class Session3 extends HeyApiClient { /** - * Find text + * List v2 sessions * - * Search for text patterns across files in the project using ripgrep. - */ - public text( - parameters: { - directory?: string - workspace?: string - pattern: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "pattern" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find", - ...options, - ...params, - }) - } - - /** - * Find files - * - * Search for files or directories by name or pattern in the project directory. - */ - public files( - parameters: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - { in: "query", key: "dirs" }, - { in: "query", key: "type" }, - { in: "query", key: "limit" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find/file", - ...options, - ...params, - }) - } - - /** - * Find symbols - * - * Search for workspace symbols like functions, classes, and variables using LSP. - */ - public symbols( - parameters: { - directory?: string - workspace?: string - query: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find/symbol", - ...options, - ...params, - }) - } -} - -export class File extends HeyApiClient { - /** - * List files - * - * List files and directories in a specified path. + * Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list. */ public list( - parameters: { - directory?: string - workspace?: string - path: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/file", - ...options, - ...params, - }) - } - - /** - * Read file - * - * Read the content of a specified file. - */ - public read( - parameters: { - directory?: string - workspace?: string - path: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/file/content", - ...options, - ...params, - }) - } - - /** - * Get file status - * - * Get the git status of all files in the project. - */ - public status( parameters?: { directory?: string workspace?: string @@ -3371,57 +3986,25 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/file/status", + return (options?.client ?? this.client).get({ + url: "/api/session", ...options, ...params, }) } -} -export class Event extends HeyApiClient { /** - * Subscribe to events + * Send v2 message * - * Get events + * Create a v2 session message and queue it for the agent loop. */ - public subscribe( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) - } -} - -export class Auth2 extends HeyApiClient { - /** - * Remove MCP OAuth - * - * Remove OAuth credentials for an MCP server - */ - public remove( + public prompt( parameters: { - name: string + sessionID: string directory?: string workspace?: string + prompt?: Prompt + delivery?: SessionDelivery }, options?: Options, ) { @@ -3430,81 +4013,17 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "prompt" }, + { in: "body", key: "delivery" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Start MCP OAuth - * - * Start OAuth authentication flow for a Model Context Protocol (MCP) server. - */ - public start( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Complete MCP OAuth - * - * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. - */ - public callback( - parameters: { - name: string - directory?: string - workspace?: string - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth/callback", + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/prompt", ...options, ...params, headers: { @@ -3516,13 +4035,13 @@ export class Auth2 extends HeyApiClient { } /** - * Authenticate MCP OAuth + * Compact v2 session * - * Start OAuth flow and wait for callback (opens browser) + * Compact a v2 session conversation. */ - public authenticate( + public compact( parameters: { - name: string + sessionID: string directory?: string workspace?: string }, @@ -3533,156 +4052,121 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post( - { - url: "/mcp/{name}/auth/authenticate", - ...options, - ...params, - }, + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/compact", + ...options, + ...params, + }) + } + + /** + * Wait for v2 session + * + * Wait for a v2 session agent loop to become idle. + */ + public wait( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/wait", + ...options, + ...params, + }) + } + + /** + * Get v2 session context + * + * Retrieve the active context messages for a v2 session (all messages after the last compaction). + */ + public context( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/context", + ...options, + ...params, + }) + } + + /** + * Get v2 session messages + * + * Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public messages( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) } } -export class Mcp extends HeyApiClient { - /** - * Get MCP status - * - * Get the status of all Model Context Protocol (MCP) servers. - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/mcp", - ...options, - ...params, - }) - } - - /** - * Add MCP server - * - * Dynamically add a new Model Context Protocol (MCP) server to the system. - */ - public add( - parameters?: { - directory?: string - workspace?: string - name?: string - config?: McpLocalConfig | McpRemoteConfig - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "config" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Connect an MCP server - */ - public connect( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/connect", - ...options, - ...params, - }) - } - - /** - * Disconnect an MCP server - */ - public disconnect( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/disconnect", - ...options, - ...params, - }) - } - - private _auth?: Auth2 - get auth(): Auth2 { - return (this._auth ??= new Auth2({ client: this.client })) +export class V2 extends HeyApiClient { + private _session?: Session3 + get session(): Session3 { + return (this._session ??= new Session3({ client: this.client })) } } @@ -3690,7 +4174,7 @@ export class Control extends HeyApiClient { /** * Get next TUI request * - * Retrieve the next TUI (Terminal User Interface) request from the queue for processing. + * Retrieve the next TUI request from the queue for processing. */ public next( parameters?: { @@ -3759,7 +4243,7 @@ export class Tui extends HeyApiClient { /** * Append TUI prompt * - * Append prompt to the TUI + * Append prompt to the TUI. */ public appendPrompt( parameters?: { @@ -3826,7 +4310,7 @@ export class Tui extends HeyApiClient { /** * Open sessions dialog * - * Open the session dialog + * Open the session dialog. */ public openSessions( parameters?: { @@ -3856,7 +4340,7 @@ export class Tui extends HeyApiClient { /** * Open themes dialog * - * Open the theme dialog + * Open the theme dialog. */ public openThemes( parameters?: { @@ -3886,7 +4370,7 @@ export class Tui extends HeyApiClient { /** * Open models dialog * - * Open the model dialog + * Open the model dialog. */ public openModels( parameters?: { @@ -3916,7 +4400,7 @@ export class Tui extends HeyApiClient { /** * Submit TUI prompt * - * Submit the prompt + * Submit the prompt. */ public submitPrompt( parameters?: { @@ -3946,7 +4430,7 @@ export class Tui extends HeyApiClient { /** * Clear TUI prompt * - * Clear the prompt + * Clear the prompt. */ public clearPrompt( parameters?: { @@ -3976,7 +4460,7 @@ export class Tui extends HeyApiClient { /** * Execute TUI command * - * Execute a TUI command (e.g. agent_cycle) + * Execute a TUI command. */ public executeCommand( parameters?: { @@ -4013,7 +4497,7 @@ export class Tui extends HeyApiClient { /** * Show TUI toast * - * Show a toast notification in the TUI + * Show a toast notification in the TUI. */ public showToast( parameters?: { @@ -4056,13 +4540,13 @@ export class Tui extends HeyApiClient { /** * Publish TUI event * - * Publish a TUI event + * Publish a TUI event. */ public publish( parameters?: { directory?: string workspace?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 }, options?: Options, ) { @@ -4133,230 +4617,6 @@ export class Tui extends HeyApiClient { } } -export class Instance extends HeyApiClient { - /** - * Dispose instance - * - * Clean up and dispose the current OpenCode instance, releasing all resources. - */ - public dispose( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/instance/dispose", - ...options, - ...params, - }) - } -} - -export class Path extends HeyApiClient { - /** - * Get paths - * - * Retrieve the current working directory and related path information for the OpenCode instance. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/path", - ...options, - ...params, - }) - } -} - -export class Vcs extends HeyApiClient { - /** - * Get VCS info - * - * Retrieve version control system (VCS) information for the current project, such as git branch. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs", - ...options, - ...params, - }) - } - - /** - * Get VCS diff - * - * Retrieve the current git diff for the working tree or against the default branch. - */ - public diff( - parameters: { - directory?: string - workspace?: string - mode: "git" | "branch" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "mode" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs/diff", - ...options, - ...params, - }) - } -} - -export class Command extends HeyApiClient { - /** - * List commands - * - * Get a list of all available commands in the OpenCode system. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/command", - ...options, - ...params, - }) - } -} - -export class Lsp extends HeyApiClient { - /** - * Get LSP status - * - * Get LSP server status - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/lsp", - ...options, - ...params, - }) - } -} - -export class Formatter extends HeyApiClient { - /** - * Get formatter status - * - * Get formatter status - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/formatter", - ...options, - ...params, - }) - } -} - export class KiloClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -4365,11 +4625,6 @@ export class KiloClient extends HeyApiClient { KiloClient.__registry.set(this, args?.key) } - private _global?: Global - get global(): Global { - return (this._global ??= new Global({ client: this.client })) - } - private _auth?: Auth get auth(): Auth { return (this._auth ??= new Auth({ client: this.client })) @@ -4380,19 +4635,14 @@ export class KiloClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) + private _global?: Global + get global(): Global { + return (this._global ??= new Global({ client: this.client })) } - private _project?: Project - get project(): Project { - return (this._project ??= new Project({ client: this.client })) - } - - private _pty?: Pty - get pty(): Pty { - return (this._pty ??= new Pty({ client: this.client })) + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) } private _config?: Config2 @@ -4400,6 +4650,11 @@ export class KiloClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) @@ -4410,36 +4665,6 @@ export class KiloClient extends HeyApiClient { return (this._worktree ??= new Worktree({ client: this.client })) } - private _session?: Session2 - get session(): Session2 { - return (this._session ??= new Session2({ client: this.client })) - } - - private _part?: Part - get part(): Part { - return (this._part ??= new Part({ client: this.client })) - } - - private _permission?: Permission - get permission(): Permission { - return (this._permission ??= new Permission({ client: this.client })) - } - - private _question?: Question - get question(): Question { - return (this._question ??= new Question({ client: this.client })) - } - - private _provider?: Provider - get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) - } - - private _sync?: Sync - get sync(): Sync { - return (this._sync ??= new Sync({ client: this.client })) - } - private _find?: Find get find(): Find { return (this._find ??= new Find({ client: this.client })) @@ -4450,21 +4675,6 @@ export class KiloClient extends HeyApiClient { return (this._file ??= new File({ client: this.client })) } - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } - - private _mcp?: Mcp - get mcp(): Mcp { - return (this._mcp ??= new Mcp({ client: this.client })) - } - - private _tui?: Tui - get tui(): Tui { - return (this._tui ??= new Tui({ client: this.client })) - } - private _instance?: Instance get instance(): Instance { return (this._instance ??= new Instance({ client: this.client })) @@ -4494,4 +4704,59 @@ export class KiloClient extends HeyApiClient { get formatter(): Formatter { return (this._formatter ??= new Formatter({ client: this.client })) } + + private _mcp?: Mcp + get mcp(): Mcp { + return (this._mcp ??= new Mcp({ client: this.client })) + } + + private _project?: Project + get project(): Project { + return (this._project ??= new Project({ client: this.client })) + } + + private _pty?: Pty + get pty(): Pty { + return (this._pty ??= new Pty({ client: this.client })) + } + + private _question?: Question + get question(): Question { + return (this._question ??= new Question({ client: this.client })) + } + + private _permission?: Permission + get permission(): Permission { + return (this._permission ??= new Permission({ client: this.client })) + } + + private _provider?: Provider + get provider(): Provider { + return (this._provider ??= new Provider({ client: this.client })) + } + + private _session?: Session2 + get session(): Session2 { + return (this._session ??= new Session2({ client: this.client })) + } + + private _part?: Part + get part(): Part { + return (this._part ??= new Part({ client: this.client })) + } + + private _sync?: Sync + get sync(): Sync { + return (this._sync ??= new Sync({ client: this.client })) + } + + private _v2?: V2 + get v2(): V2 { + return (this._v2 ??= new V2({ client: this.client })) + } + + private _tui?: Tui + get tui(): Tui { + return (this._tui ??= new Tui({ client: this.client })) + } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b925ec6096..86c5a762b1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,96 +4,105 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type Project = { - id: string - worktree: string - vcs?: "git" - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } - sandboxes: Array +export type Event = + | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventProjectUpdated + | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextStepFailed + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolFailed + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed + +export type OAuth = { + type: "oauth" + refresh: string + access: string + expires: number + accountId?: string + enterpriseUrl?: string } -export type EventProjectUpdated = { - type: "project.updated" - properties: Project -} - -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string +export type ApiAuth = { + type: "api" + key: string + metadata?: { + [key: string]: string } } -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } +export type WellKnownAuth = { + type: "wellknown" + key: string + token: string } -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type EventLspClientDiagnostics = { - type: "lsp.client.diagnostics" - properties: { - serverID: string - path: string - } -} - -export type EventLspUpdated = { - type: "lsp.updated" - properties: { - [key: string]: unknown - } -} - -export type EventMessagePartDelta = { - type: "message.part.delta" - properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } -} +export type Auth = OAuth | ApiAuth | WellKnownAuth export type PermissionRequest = { id: string @@ -110,20 +119,6 @@ export type PermissionRequest = { } } -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - export type SnapshotFileDiff = { file: string patch: string @@ -132,14 +127,6 @@ export type SnapshotFileDiff = { status?: "added" | "deleted" | "modified" } -export type EventSessionDiff = { - type: "session.diff" - properties: { - sessionID: string - diff: Array - } -} - export type ProviderAuthError = { name: "ProviderAuthError" data: { @@ -201,35 +188,6 @@ export type ApiError = { } } -export type EventSessionError = { - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } -} - -export type EventInstallationUpdated = { - type: "installation.updated" - properties: { - version: string - } -} - -export type EventInstallationUpdateAvailable = { - type: "installation.update-available" - properties: { - version: string - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -254,13 +212,7 @@ export type QuestionInfo = { * Available choices */ options: Array - /** - * Allow selecting multiple choices - */ multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ custom?: boolean } @@ -279,11 +231,6 @@ export type QuestionRequest = { tool?: QuestionTool } -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - export type QuestionAnswer = Array export type QuestionReplied = { @@ -292,21 +239,11 @@ export type QuestionReplied = { answers: Array } -export type EventQuestionReplied = { - type: "question.replied" - properties: QuestionReplied -} - export type QuestionRejected = { sessionID: string requestID: string } -export type EventQuestionRejected = { - type: "question.rejected" - properties: QuestionRejected -} - export type Todo = { /** * Brief description of the task @@ -322,14 +259,6 @@ export type Todo = { priority: string } -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type SessionStatus = | { type: "idle" @@ -344,29 +273,8 @@ export type SessionStatus = type: "busy" } -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - export type EventTuiPromptAppend = { + id: string type: "tui.prompt.append" properties: { text: string @@ -374,6 +282,7 @@ export type EventTuiPromptAppend = { } export type EventTuiCommandExecute = { + id: string type: "tui.command.execute" properties: { command: @@ -398,19 +307,18 @@ export type EventTuiCommandExecute = { } export type EventTuiToastShow = { + id: string type: "tui.toast.show" properties: { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } } export type EventTuiSessionSelect = { + id: string type: "tui.session.select" properties: { /** @@ -420,83 +328,28 @@ export type EventTuiSessionSelect = { } } -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string +export type Project = { + id: string + worktree: string + vcs?: "git" + name?: string + icon?: { + url?: string + override?: string + color?: string } -} - -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventWorktreeReady = { - type: "worktree.ready" - properties: { - name: string - branch: string - } -} - -export type EventWorktreeFailed = { - type: "worktree.failed" - properties: { - message: string + time: { + created: number + updated: number + initialized?: number } + sandboxes: Array } export type Pty = { @@ -509,35 +362,6 @@ export type Pty = { pid: number } -export type EventPtyCreated = { - type: "pty.created" - properties: { - info: Pty - } -} - -export type EventPtyUpdated = { - type: "pty.updated" - properties: { - info: Pty - } -} - -export type EventPtyExited = { - type: "pty.exited" - properties: { - id: string - exitCode: number - } -} - -export type EventPtyDeleted = { - type: "pty.deleted" - properties: { - id: string - } -} - export type OutputFormatText = { type: "text" } @@ -623,22 +447,6 @@ export type AssistantMessage = { export type Message = UserMessage | AssistantMessage -export type EventMessageUpdated = { - type: "message.updated" - properties: { - sessionID: string - info: Message - } -} - -export type EventMessageRemoved = { - type: "message.removed" - properties: { - sessionID: string - messageID: string - } -} - export type TextPart = { id: string sessionID: string @@ -902,24 +710,6 @@ export type Part = | RetryPart | CompactionPart -export type EventMessagePartUpdated = { - type: "message.part.updated" - properties: { - sessionID: string - part: Part - time: number - } -} - -export type EventMessagePartRemoved = { - type: "message.part.removed" - properties: { - sessionID: string - messageID: string - partID: string - } -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -948,6 +738,12 @@ export type Session = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -964,146 +760,10 @@ export type Session = { } } -export type EventSessionCreated = { - type: "session.created" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionUpdated = { - type: "session.updated" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionDeleted = { - type: "session.deleted" - properties: { - sessionID: string - info: Session - } -} - -export type SyncEventMessageUpdated = { - type: "sync" - name: "message.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Message - } -} - -export type SyncEventMessageRemoved = { - type: "sync" - name: "message.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - } -} - -export type SyncEventMessagePartUpdated = { - type: "sync" - name: "message.part.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - part: Part - time: number - } -} - -export type SyncEventMessagePartRemoved = { - type: "sync" - name: "message.part.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SyncEventSessionCreated = { - type: "sync" - name: "session.created.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type SyncEventSessionUpdated = { - type: "sync" - name: "session.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: { - id?: string | null - slug?: string | null - projectID?: string | null - workspaceID?: string | null - directory?: string | null - path?: string | null - parentID?: string | null - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } | null - share?: { - url?: string | null - } - title?: string | null - version?: string | null - time?: { - created?: number | null - updated?: number | null - compacting?: number | null - archived?: number | null - } - permission?: PermissionRuleset | null - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } | null - } - } -} - -export type SyncEventSessionDeleted = { - type: "sync" - name: "session.deleted.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } +export type Prompt = { + text: string + files?: Array + agents?: Array } export type GlobalEvent = { @@ -1111,10 +771,7 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventProjectUpdated | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed | EventFileEdited | EventFileWatcherUpdated | EventLspClientDiagnostics @@ -1140,6 +797,7 @@ export type GlobalEvent = { | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventProjectUpdated | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed @@ -1158,6 +816,34 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextStepFailed + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolFailed + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -1165,6 +851,32 @@ export type GlobalEvent = { | SyncEventSessionCreated | SyncEventSessionUpdated | SyncEventSessionDeleted + | SyncEventSessionNextAgentSwitched + | SyncEventSessionNextModelSwitched + | SyncEventSessionNextPrompted + | SyncEventSessionNextSynthetic + | SyncEventSessionNextShellStarted + | SyncEventSessionNextShellEnded + | SyncEventSessionNextStepStarted + | SyncEventSessionNextStepEnded + | SyncEventSessionNextStepFailed + | SyncEventSessionNextTextStarted + | SyncEventSessionNextTextDelta + | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded + | SyncEventSessionNextToolInputStarted + | SyncEventSessionNextToolInputDelta + | SyncEventSessionNextToolInputEnded + | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress + | SyncEventSessionNextToolSuccess + | SyncEventSessionNextToolFailed + | SyncEventSessionNextRetried + | SyncEventSessionNextCompactionStarted + | SyncEventSessionNextCompactionDelta + | SyncEventSessionNextCompactionEnded } /** @@ -1176,25 +888,10 @@ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" * Server configuration for opencode serve and web commands */ export type ServerConfig = { - /** - * Port to listen on - */ port?: number - /** - * Hostname to listen on - */ hostname?: string - /** - * Enable mDNS service discovery - */ mdns?: boolean - /** - * Custom domain name for mDNS service (default: opencode.local) - */ mdnsDomain?: string - /** - * Additional domains to allow for CORS - */ cors?: Array } @@ -1229,28 +926,16 @@ export type PermissionConfig = export type AgentConfig = { model?: string - /** - * Default model variant for this agent (applies only when using the agent's configured model). - */ variant?: string temperature?: number top_p?: number prompt?: string - /** - * @deprecated Use 'permission' field instead - */ tools?: { [key: string]: boolean } disable?: boolean - /** - * Description of when to use the agent - */ description?: string mode?: "subagent" | "primary" | "all" - /** - * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) - */ hidden?: boolean options?: { [key: string]: unknown @@ -1259,13 +944,7 @@ export type AgentConfig = { * Hex color code (e.g., #FF5733) or theme color (e.g., primary) */ color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - /** - * Maximum number of agentic iterations before forcing text-only response - */ steps?: number - /** - * @deprecated Use 'steps' field instead. - */ maxSteps?: number permission?: PermissionConfig [key: string]: @@ -1306,21 +985,12 @@ export type ProviderConfig = { options?: { apiKey?: string baseURL?: string - /** - * GitHub Enterprise URL for copilot authentication - */ enterpriseUrl?: string - /** - * Enable promptCacheKey for this provider (default false) - */ setCacheKey?: boolean /** * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ timeout?: number | false - /** - * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. - */ chunkTimeout?: number [key: string]: unknown | string | boolean | number | false | number | undefined } @@ -1377,9 +1047,6 @@ export type ProviderConfig = { */ variants?: { [key: string]: { - /** - * Disable this variant for the model - */ disabled?: boolean [key: string]: unknown | boolean | undefined } @@ -1397,38 +1064,17 @@ export type McpLocalConfig = { * Command and arguments to run the MCP server */ command: Array - /** - * Environment variables to set when running the MCP server - */ environment?: { [key: string]: string } - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } export type McpOAuthConfig = { - /** - * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. - */ clientId?: string - /** - * OAuth client secret (if required by the authorization server) - */ clientSecret?: string - /** - * OAuth scopes to request during authorization - */ scope?: string - /** - * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). - */ redirectUri?: string } @@ -1441,13 +1087,7 @@ export type McpRemoteConfig = { * URL of the remote MCP server */ url: string - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Headers to send with the request - */ headers?: { [key: string]: string } @@ -1455,9 +1095,6 @@ export type McpRemoteConfig = { * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. */ oauth?: McpOAuthConfig | false - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } @@ -1467,19 +1104,10 @@ export type McpRemoteConfig = { export type LayoutConfig = "auto" | "stretch" export type Config = { - /** - * JSON schema reference for configuration validation - */ $schema?: string - /** - * Default shell to use for terminal and bash tool - */ shell?: string logLevel?: LogLevel server?: ServerConfig - /** - * Command configuration, see https://opencode.ai/docs/commands - */ command?: { [key: string]: { template: string @@ -1489,25 +1117,13 @@ export type Config = { subtask?: boolean } } - /** - * Additional skill folder paths - */ skills?: { - /** - * Additional paths to skill folders - */ paths?: Array - /** - * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) - */ urls?: Array } watcher?: { ignore?: Array } - /** - * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. - */ snapshot?: boolean plugin?: Array< | string @@ -1518,53 +1134,23 @@ export type Config = { }, ] > - /** - * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing - */ share?: "manual" | "auto" | "disabled" - /** - * @deprecated Use 'share' field instead. Share newly created sessions automatically - */ autoshare?: boolean /** * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications */ autoupdate?: boolean | "notify" - /** - * Disable providers that are loaded automatically - */ disabled_providers?: Array - /** - * When set, ONLY these providers will be enabled. All other providers will be ignored - */ enabled_providers?: Array - /** - * Model to use in the format of provider/model, eg anthropic/claude-2 - */ model?: string - /** - * Small model to use for tasks like title generation in the format of provider/model - */ small_model?: string - /** - * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. - */ default_agent?: string - /** - * Custom username to display in conversations instead of system username - */ username?: string - /** - * @deprecated Use `agent` field instead. - */ mode?: { build?: AgentConfig plan?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Agent configuration, see https://opencode.ai/docs/agents - */ agent?: { plan?: AgentConfig build?: AgentConfig @@ -1575,15 +1161,9 @@ export type Config = { compaction?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Custom provider configurations and model overrides - */ provider?: { [key: string]: ProviderConfig } - /** - * MCP (Model Context Protocol) server configurations - */ mcp?: { [key: string]: | McpLocalConfig @@ -1592,6 +1172,9 @@ export type Config = { enabled: boolean } } + /** + * Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ formatter?: | boolean | { @@ -1604,6 +1187,9 @@ export type Config = { extensions?: Array } } + /** + * Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ lsp?: | boolean | { @@ -1623,9 +1209,6 @@ export type Config = { } } } - /** - * Additional instruction files or patterns to include - */ instructions?: Array layout?: LayoutConfig permission?: PermissionConfig @@ -1633,121 +1216,29 @@ export type Config = { [key: string]: boolean } enterprise?: { - /** - * Enterprise URL - */ url?: string } - /** - * Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned. - */ tool_output?: { - /** - * Maximum lines of tool output before it is truncated and saved to disk (default: 2000) - */ max_lines?: number - /** - * Maximum bytes of tool output before it is truncated and saved to disk (default: 51200) - */ max_bytes?: number } compaction?: { - /** - * Enable automatic compaction when context is full (default: true) - */ auto?: boolean - /** - * Enable pruning of old tool outputs (default: true) - */ prune?: boolean - /** - * Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2) - */ tail_turns?: number - /** - * Maximum number of tokens from recent turns to preserve verbatim after compaction - */ preserve_recent_tokens?: number - /** - * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. - */ reserved?: number } experimental?: { disable_paste_summary?: boolean - /** - * Enable the batch tool - */ batch_tool?: boolean - /** - * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) - */ openTelemetry?: boolean - /** - * Tools that should only be available to primary agents. - */ primary_tools?: Array - /** - * Continue the agent loop when a tool call is denied - */ continue_loop_on_deny?: boolean - /** - * Timeout in milliseconds for model context protocol (MCP) requests - */ mcp_timeout?: number } } -export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false -} - -export type OAuth = { - type: "oauth" - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string -} - -export type ApiAuth = { - type: "api" - key: string - metadata?: { - [key: string]: string - } -} - -export type WellKnownAuth = { - type: "wellknown" - key: string - token: string -} - -export type Auth = OAuth | ApiAuth | WellKnownAuth - -export type Workspace = { - id: string - type: string - name: string - branch: string | null - directory: string | null - extra: unknown | null - projectID: string -} - -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type Model = { id: string providerID: string @@ -1839,8 +1330,6 @@ export type ConsoleState = { switchableOrgCount: number } -export type ToolIds = Array - export type ToolListItem = { id: string description: string @@ -1849,11 +1338,7 @@ export type ToolListItem = { export type ToolList = Array -export type Worktree = { - name: string - branch: string - directory: string -} +export type ToolIds = Array export type WorktreeCreateInput = { name?: string @@ -1863,6 +1348,12 @@ export type WorktreeCreateInput = { startCommand?: string } +export type Worktree = { + name: string + branch: string + directory: string +} + export type WorktreeRemoveInput = { directory: string } @@ -1895,6 +1386,12 @@ export type GlobalSession = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -1920,93 +1417,6 @@ export type McpResource = { client: string } -export type TextPartInput = { - id?: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean - time?: { - start: number - end?: number - } - metadata?: { - [key: string]: unknown - } -} - -export type FilePartInput = { - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} - -export type AgentPartInput = { - id?: string - type: "agent" - name: string - source?: { - value: string - start: number - end: number - } -} - -export type SubtaskPartInput = { - id?: string - type: "subtask" - prompt: string - description: string - agent: string - model?: { - providerID: string - modelID: string - } - command?: string -} - -export type ProviderAuthMethod = { - type: "oauth" | "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - > -} - -export type ProviderAuthAuthorization = { - url: string - method: "auto" | "code" - instructions: string -} - export type Symbol = { name: string kind: number @@ -2053,88 +1463,6 @@ export type File = { status: "added" | "deleted" | "modified" } -export type Event = - | EventProjectUpdated - | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - -export type McpStatusConnected = { - status: "connected" -} - -export type McpStatusDisabled = { - status: "disabled" -} - -export type McpStatusFailed = { - status: "failed" - error: string -} - -export type McpStatusNeedsAuth = { - status: "needs_auth" -} - -export type McpStatusNeedsClientRegistration = { - status: "needs_client_registration" - error: string -} - -export type McpStatus = - | McpStatusConnected - | McpStatusDisabled - | McpStatusFailed - | McpStatusNeedsAuth - | McpStatusNeedsClientRegistration - -export type McpUnsupportedOAuthError = { - error: string -} - export type Path = { home: string state: string @@ -2202,6 +1530,1827 @@ export type FormatterStatus = { enabled: boolean } +export type McpStatusConnected = { + status: "connected" +} + +export type McpStatusDisabled = { + status: "disabled" +} + +export type McpStatusFailed = { + status: "failed" + error: string +} + +export type McpStatusNeedsAuth = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration" + error: string +} + +export type McpStatus = + | McpStatusConnected + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration + +export type McpUnsupportedOAuthError = { + error: string +} + +export type EffectHttpApiErrorForbidden = { + _tag: "Forbidden" +} + +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + > +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string + model?: { + providerID: string + modelID: string + } + command?: string +} + +export type V2SessionsResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type V2SessionMessagesResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type EventTuiPromptAppend2 = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute2 = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow2 = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect2 = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type Workspace = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type SyncEventMessageUpdated = { + type: "sync" + name: "message.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Message + } +} + +export type SyncEventMessageRemoved = { + type: "sync" + name: "message.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + } +} + +export type SyncEventMessagePartUpdated = { + type: "sync" + name: "message.part.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + part: Part + time: number + } +} + +export type SyncEventMessagePartRemoved = { + type: "sync" + name: "message.part.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SyncEventSessionCreated = { + type: "sync" + name: "session.created.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionUpdated = { + type: "sync" + name: "session.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: { + id?: string | null + slug?: string | null + projectID?: string | null + workspaceID?: string | null + directory?: string | null + path?: string | null + parentID?: string | null + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } | null + share?: { + url?: string | null + } + title?: string | null + agent?: string | null + model?: { + id: string + providerID: string + variant?: string + } | null + version?: string | null + time?: { + created?: number | null + updated?: number | null + compacting?: number | null + archived?: number | null + } + permission?: PermissionRuleset | null + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } | null + } + } +} + +export type SyncEventSessionDeleted = { + type: "sync" + name: "session.deleted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionNextAgentSwitched = { + type: "sync" + name: "session.next.agent.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + } +} + +export type SyncEventSessionNextModelSwitched = { + type: "sync" + name: "session.next.model.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + id: string + providerID: string + variant?: string + } +} + +export type SyncEventSessionNextPrompted = { + type: "sync" + name: "session.next.prompted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type SyncEventSessionNextSynthetic = { + type: "sync" + name: "session.next.synthetic.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextShellStarted = { + type: "sync" + name: "session.next.shell.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type SyncEventSessionNextShellEnded = { + type: "sync" + name: "session.next.shell.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SyncEventSessionNextStepStarted = { + type: "sync" + name: "session.next.step.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type SyncEventSessionNextStepEnded = { + type: "sync" + name: "session.next.step.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type SyncEventSessionNextStepFailed = { + type: "sync" + name: "session.next.step.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + error: { + type: string + message: string + } + } +} + +export type SyncEventSessionNextTextStarted = { + type: "sync" + name: "session.next.text.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + } +} + +export type SyncEventSessionNextTextDelta = { + type: "sync" + name: "session.next.text.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + delta: string + } +} + +export type SyncEventSessionNextTextEnded = { + type: "sync" + name: "session.next.text.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type SyncEventSessionNextToolInputStarted = { + type: "sync" + name: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type SyncEventSessionNextToolInputDelta = { + type: "sync" + name: "session.next.tool.input.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type SyncEventSessionNextToolInputEnded = { + type: "sync" + name: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type SyncEventSessionNextToolCalled = { + type: "sync" + name: "session.next.tool.called.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SyncEventSessionNextToolSuccess = { + type: "sync" + name: "session.next.tool.success.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolFailed = { + type: "sync" + name: "session.next.tool.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + error: { + type: string + message: string + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextRetried = { + type: "sync" + name: "session.next.retried.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SyncEventSessionNextCompactionStarted = { + type: "sync" + name: "session.next.compaction.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type SyncEventSessionNextCompactionDelta = { + type: "sync" + name: "session.next.compaction.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextCompactionEnded = { + type: "sync" + name: "session.next.compaction.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventLspClientDiagnostics = { + id: string + type: "lsp.client.diagnostics" + properties: { + serverID: string + path: string + } +} + +export type EventLspUpdated = { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +export type EventMessagePartDelta = { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type EventPermissionAsked = { + id: string + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventSessionDiff = { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } +} + +export type EventInstallationUpdated = { + id: string + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable = { + id: string + type: "installation.update-available" + properties: { + version: string + } +} + +export type EventQuestionAsked = { + id: string + type: "question.asked" + properties: QuestionRequest +} + +export type EventQuestionReplied = { + id: string + type: "question.replied" + properties: QuestionReplied +} + +export type EventQuestionRejected = { + id: string + type: "question.rejected" + properties: QuestionRejected +} + +export type EventTodoUpdated = { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventSessionStatus = { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + id: string + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionCompacted = { + id: string + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventMcpToolsChanged = { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventProjectUpdated = { + id: string + type: "project.updated" + properties: Project +} + +export type EventVcsBranchUpdated = { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventWorkspaceReady = { + id: string + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + id: string + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + id: string + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventWorktreeReady = { + id: string + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + id: string + type: "worktree.failed" + properties: { + message: string + } +} + +export type EventPtyCreated = { + id: string + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + id: string + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + id: string + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventMessageUpdated = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionNextAgentSwitched = { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + agent: string + } +} + +export type EventSessionNextModelSwitched = { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + id: string + providerID: string + variant?: string + } +} + +export type PromptSource = { + start: number + end: number + text: string +} + +export type PromptFileAttachment = { + uri: string + mime: string + name?: string + description?: string + source?: PromptSource +} + +export type PromptAgentAttachment = { + name: string + source?: PromptSource +} + +export type EventSessionNextPrompted = { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type EventSessionNextSynthetic = { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextShellStarted = { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type EventSessionNextShellEnded = { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type EventSessionNextStepStarted = { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type EventSessionNextStepEnded = { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type EventSessionNextStepFailed = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + error: { + type: string + message: string + } + } +} + +export type EventSessionNextTextStarted = { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextTextDelta = { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + delta: string + } +} + +export type EventSessionNextTextEnded = { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextReasoningStarted = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type EventSessionNextReasoningDelta = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type EventSessionNextToolInputStarted = { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta = { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded = { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled = { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type ToolTextContent = { + type: "text" + text: string +} + +export type ToolFileContent = { + type: "file" + uri: string + mime: string + name?: string +} + +export type EventSessionNextToolProgress = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type EventSessionNextToolSuccess = { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextToolFailed = { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + callID: string + error: { + type: string + message: string + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } +} + +export type EventSessionNextRetried = { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompactionStarted = { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type EventSessionNextCompactionDelta = { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextCompactionEnded = { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type SessionInfo = { + id: string + parentID?: string + projectID: string + workspaceID?: string + path?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + time: { + created: number + updated: number + archived?: number + } + title: string +} + +export type SessionDelivery = "immediate" | "deferred" + +export type SessionMessageAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "agent-switched" + agent: string +} + +export type SessionMessageModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: { + id: string + providerID: string + variant?: string + } +} + +export type SessionMessageUser = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionMessageSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + sessionID: string + text: string + type: "synthetic" +} + +export type SessionMessageShell = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + callID: string + command: string + output: string +} + +export type SessionMessageAssistantText = { + type: "text" + text: string +} + +export type SessionMessageAssistantReasoning = { + type: "reasoning" + id: string + text: string +} + +export type SessionMessageToolStatePending = { + status: "pending" + input: string +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + structured: { + [key: string]: unknown + } + content: Array +} + +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + attachments?: Array + content: Array + structured: { + [key: string]: unknown + } +} + +export type SessionMessageToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + content: Array + structured: { + [key: string]: unknown + } + error: { + type: string + message: string + } +} + +export type SessionMessageAssistantTool = { + type: "tool" + id: string + name: string + provider?: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number + } +} + +export type SessionMessageAssistant = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: { + id: string + providerID: string + variant?: string + } + content: Array + snapshot?: { + start?: string + end?: string + } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: { + type: string + message: string + } +} + +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + include?: string + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } +} + +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type EventTuiToastShow1 = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type BadRequestError = { + data: unknown + errors: Array<{ + [key: string]: unknown + }> + success: false +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + +export type AuthRemoveData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] + +export type AuthRemoveResponses = { + /** + * Successfully removed authentication credentials + */ + 200: boolean +} + +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] + +export type AuthSetData = { + body?: Auth + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthSetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] + +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean +} + +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] + +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn" + /** + * Log message + */ + message: string + extra?: { + [key: string]: unknown + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/log" +} + +export type AppLogErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppLogError = AppLogErrors[keyof AppLogErrors] + +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean +} + +export type AppLogResponse = AppLogResponses[keyof AppLogResponses] + export type GlobalHealthData = { body?: never path?: never @@ -2329,276 +3478,924 @@ export type GlobalUpgradeResponses = { export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses] -export type AuthRemoveData = { - body?: never - path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} - -export type AuthRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] - -export type AuthRemoveResponses = { - /** - * Successfully removed authentication credentials - */ - 200: boolean -} - -export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] - -export type AuthSetData = { - body?: Auth - path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} - -export type AuthSetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] - -export type AuthSetResponses = { - /** - * Successfully set authentication credentials - */ - 200: boolean -} - -export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] - -export type AppLogData = { - body?: { - /** - * Service name for the log entry - */ - service: string - /** - * Log level - */ - level: "debug" | "info" | "error" | "warn" - /** - * Log message - */ - message: string - /** - * Additional metadata for the log entry - */ - extra?: { - [key: string]: unknown - } - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/log" -} - -export type AppLogErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppLogError = AppLogErrors[keyof AppLogErrors] - -export type AppLogResponses = { - /** - * Log entry written successfully - */ - 200: boolean -} - -export type AppLogResponse = AppLogResponses[keyof AppLogResponses] - -export type ExperimentalWorkspaceAdapterListData = { +export type EventSubscribeData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/adapter" + url: "/event" } -export type ExperimentalWorkspaceAdapterListResponses = { +export type EventSubscribeResponses = { /** - * Workspace adapters + * Event stream */ - 200: Array<{ - type: string - name: string - description: string - }> + 200: Event } -export type ExperimentalWorkspaceAdapterListResponse = - ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] -export type ExperimentalWorkspaceListData = { +export type ConfigGetData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/config" } -export type ExperimentalWorkspaceListResponses = { +export type ConfigGetResponses = { /** - * Workspaces + * Get config info */ - 200: Array + 200: Config } -export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] -export type ExperimentalWorkspaceCreateData = { - body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } +export type ConfigUpdateData = { + body?: Config path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/config" } -export type ExperimentalWorkspaceCreateErrors = { +export type ConfigUpdateErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] -export type ExperimentalWorkspaceCreateResponses = { +export type ConfigUpdateResponses = { /** - * Workspace created + * Successfully updated config */ - 200: Workspace + 200: Config } -export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] -export type ExperimentalWorkspaceStatusData = { +export type ConfigProvidersData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/status" + url: "/config/providers" } -export type ExperimentalWorkspaceStatusResponses = { +export type ConfigProvidersResponses = { /** - * Workspace status - */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - }> -} - -export type ExperimentalWorkspaceStatusResponse = - ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] - -export type ExperimentalWorkspaceRemoveData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}" -} - -export type ExperimentalWorkspaceRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] - -export type ExperimentalWorkspaceRemoveResponses = { - /** - * Workspace removed - */ - 200: Workspace -} - -export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] - -export type ExperimentalWorkspaceSessionRestoreData = { - body?: { - sessionID: string - } - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}/session-restore" -} - -export type ExperimentalWorkspaceSessionRestoreErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] - -export type ExperimentalWorkspaceSessionRestoreResponses = { - /** - * Session replay started + * List of providers */ 200: { - total: number + providers: Array + default: { + [key: string]: string + } } } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] + +export type ExperimentalConsoleGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console" +} + +export type ExperimentalConsoleGetResponses = { + /** + * Active Console provider metadata + */ + 200: ConsoleState +} + +export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] + +export type ExperimentalConsoleListOrgsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/orgs" +} + +export type ExperimentalConsoleListOrgsResponses = { + /** + * Switchable Console orgs + */ + 200: { + orgs: Array<{ + accountID: string + accountEmail: string + accountUrl: string + orgID: string + orgName: string + active: boolean + }> + } +} + +export type ExperimentalConsoleListOrgsResponse = + ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] + +export type ExperimentalConsoleSwitchOrgData = { + body?: { + accountID: string + orgID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/switch" +} + +export type ExperimentalConsoleSwitchOrgResponses = { + /** + * Switch success + */ + 200: boolean +} + +export type ExperimentalConsoleSwitchOrgResponse = + ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] + +export type ToolListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + provider: string + model: string + } + url: "/experimental/tool" +} + +export type ToolListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolListError = ToolListErrors[keyof ToolListErrors] + +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList +} + +export type ToolListResponse = ToolListResponses[keyof ToolListResponses] + +export type ToolIdsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/tool/ids" +} + +export type ToolIdsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] + +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds +} + +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] + +export type WorktreeRemoveData = { + body?: WorktreeRemoveInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] + +export type WorktreeRemoveResponses = { + /** + * Worktree removed + */ + 200: boolean +} + +export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] + +export type WorktreeListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeListResponses = { + /** + * List of worktree directories + */ + 200: Array +} + +export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] + +export type WorktreeCreateData = { + body?: WorktreeCreateInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] + +export type WorktreeCreateResponses = { + /** + * Worktree created + */ + 200: Worktree +} + +export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] + +export type WorktreeResetData = { + body?: WorktreeResetInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree/reset" +} + +export type WorktreeResetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] + +export type WorktreeResetResponses = { + /** + * Worktree reset + */ + 200: boolean +} + +export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] + +export type ExperimentalSessionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + } + url: "/experimental/session" +} + +export type ExperimentalSessionListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] + +export type ExperimentalResourceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/resource" +} + +export type ExperimentalResourceListResponses = { + /** + * MCP resources + */ + 200: { + [key: string]: McpResource + } +} + +export type ExperimentalResourceListResponse = + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] + +export type FindTextData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + pattern: string + } + url: "/find" +} + +export type FindTextResponses = { + /** + * Matches + */ + 200: Array<{ + path: { + text: string + } + lines: { + text: string + } + line_number: number + absolute_offset: number + submatches: Array<{ + match: { + text: string + } + start: number + end: number + }> + }> +} + +export type FindTextResponse = FindTextResponses[keyof FindTextResponses] + +export type FindFilesData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + } + url: "/find/file" +} + +export type FindFilesResponses = { + /** + * File paths + */ + 200: Array +} + +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] + +export type FindSymbolsData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + } + url: "/find/symbol" +} + +export type FindSymbolsResponses = { + /** + * Symbols + */ + 200: Array +} + +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] + +export type FileListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file" +} + +export type FileListResponses = { + /** + * Files and directories + */ + 200: Array +} + +export type FileListResponse = FileListResponses[keyof FileListResponses] + +export type FileReadData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file/content" +} + +export type FileReadResponses = { + /** + * File content + */ + 200: FileContent +} + +export type FileReadResponse = FileReadResponses[keyof FileReadResponses] + +export type FileStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/file/status" +} + +export type FileStatusResponses = { + /** + * File status + */ + 200: Array +} + +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] + +export type InstanceDisposeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/instance/dispose" +} + +export type InstanceDisposeResponses = { + /** + * Instance disposed + */ + 200: boolean +} + +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] + +export type PathGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/path" +} + +export type PathGetResponses = { + /** + * Path + */ + 200: Path +} + +export type PathGetResponse = PathGetResponses[keyof PathGetResponses] + +export type VcsGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs" +} + +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo +} + +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] + +export type VcsDiffData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + mode: "git" | "branch" + } + url: "/vcs/diff" +} + +export type VcsDiffResponses = { + /** + * VCS diff + */ + 200: Array +} + +export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] + +export type CommandListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/command" +} + +export type CommandListResponses = { + /** + * List of commands + */ + 200: Array +} + +export type CommandListResponse = CommandListResponses[keyof CommandListResponses] + +export type AppAgentsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/agent" +} + +export type AppAgentsResponses = { + /** + * List of agents + */ + 200: Array +} + +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] + +export type AppSkillsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/skill" +} + +export type AppSkillsResponses = { + /** + * List of skills + */ + 200: Array<{ + name: string + description: string + location: string + content: string + }> +} + +export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] + +export type LspStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp" +} + +export type LspStatusResponses = { + /** + * LSP server status + */ + 200: Array +} + +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] + +export type FormatterStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/formatter" +} + +export type FormatterStatusResponses = { + /** + * Formatter status + */ + 200: Array +} + +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] + +export type McpStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpStatusResponses = { + /** + * MCP server status + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] + +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpAddErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpAddError = McpAddErrors[keyof McpAddErrors] + +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] + +export type McpAuthRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] + +export type McpAuthRemoveResponses = { + /** + * OAuth credentials removed + */ + 200: { + success: true + } +} + +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] + +export type McpAuthStartData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthStartErrors = { + /** + * McpUnsupportedOAuthError + */ + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] + +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + authorizationUrl: string + oauthState: string + } +} + +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] + +export type McpAuthCallbackData = { + body?: { + code: string + } + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/callback" +} + +export type McpAuthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] + +export type McpAuthCallbackResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] + +export type McpAuthAuthenticateData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/authenticate" +} + +export type McpAuthAuthenticateErrors = { + /** + * McpUnsupportedOAuthError + */ + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] + +export type McpAuthAuthenticateResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] + +export type McpConnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/connect" +} + +export type McpConnectResponses = { + /** + * MCP server connected successfully + */ + 200: boolean +} + +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] + +export type McpDisconnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/disconnect" +} + +export type McpDisconnectResponses = { + /** + * MCP server disconnected successfully + */ + 200: boolean +} + +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] export type ProjectListData = { body?: never @@ -2878,7 +4675,7 @@ export type PtyUpdateResponses = { export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] -export type PtyConnectData = { +export type PtyConnectTokenData = { body?: never path: { ptyID: string @@ -2887,430 +4684,313 @@ export type PtyConnectData = { directory?: string workspace?: string } - url: "/pty/{ptyID}/connect" + url: "/pty/{ptyID}/connect-token" } -export type PtyConnectErrors = { +export type PtyConnectTokenErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden /** * Not found */ 404: NotFoundError } -export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] +export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors] -export type PtyConnectResponses = { +export type PtyConnectTokenResponses = { /** - * Connected session + * WebSocket connect token */ - 200: boolean + 200: { + ticket: string + expires_in: number + } } -export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] +export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] -export type ConfigGetData = { +export type QuestionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/config" + url: "/question" } -export type ConfigGetResponses = { +export type QuestionListResponses = { /** - * Get config info + * List of pending questions */ - 200: Config + 200: Array } -export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] -export type ConfigUpdateData = { - body?: Config - path?: never +export type QuestionReplyData = { + body?: { + /** + * User answers in order of questions (each answer is an array of selected labels) + */ + answers: Array + } + path: { + requestID: string + } query?: { directory?: string workspace?: string } - url: "/config" + url: "/question/{requestID}/reply" } -export type ConfigUpdateErrors = { +export type QuestionReplyErrors = { /** * Bad request */ 400: BadRequestError -} - -export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] - -export type ConfigUpdateResponses = { /** - * Successfully updated config + * Not found */ - 200: Config + 404: NotFoundError } -export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] -export type ConfigProvidersData = { +export type QuestionReplyResponses = { + /** + * Question answered successfully + */ + 200: boolean +} + +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] + +export type QuestionRejectData = { + body?: never + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/question/{requestID}/reject" +} + +export type QuestionRejectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] + +export type QuestionRejectResponses = { + /** + * Question rejected successfully + */ + 200: boolean +} + +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] + +export type PermissionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/config/providers" + url: "/permission" } -export type ConfigProvidersResponses = { +export type PermissionListResponses = { + /** + * List of pending permissions + */ + 200: Array +} + +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] + +export type PermissionReplyData = { + body?: { + reply: "once" | "always" | "reject" + message?: string + } + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/permission/{requestID}/reply" +} + +export type PermissionReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] + +export type PermissionReplyResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] + +export type ProviderListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/provider" +} + +export type ProviderListResponses = { /** * List of providers */ 200: { - providers: Array + all: Array default: { [key: string]: string } + connected: Array } } -export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] -export type ExperimentalConsoleGetData = { +export type ProviderAuthData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console" + url: "/provider/auth" } -export type ExperimentalConsoleGetResponses = { +export type ProviderAuthResponses = { /** - * Active Console provider metadata - */ - 200: ConsoleState -} - -export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] - -export type ExperimentalConsoleListOrgsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/console/orgs" -} - -export type ExperimentalConsoleListOrgsResponses = { - /** - * Switchable Console orgs + * Provider auth methods */ 200: { - orgs: Array<{ - accountID: string - accountEmail: string - accountUrl: string - orgID: string - orgName: string - active: boolean - }> + [key: string]: Array } } -export type ExperimentalConsoleListOrgsResponse = - ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] -export type ExperimentalConsoleSwitchOrgData = { +export type ProviderOauthAuthorizeData = { body?: { - accountID: string - orgID: string + /** + * Auth method index + */ + method: number + inputs?: { + [key: string]: string + } + } + path: { + providerID: string } - path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console/switch" + url: "/provider/{providerID}/oauth/authorize" } -export type ExperimentalConsoleSwitchOrgResponses = { +export type ProviderOauthAuthorizeErrors = { /** - * Switch success + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] + +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: ProviderAuthAuthorization +} + +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] + +export type ProviderOauthCallbackData = { + body?: { + /** + * Auth method index + */ + method: number + code?: string + } + path: { + providerID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/provider/{providerID}/oauth/callback" +} + +export type ProviderOauthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] + +export type ProviderOauthCallbackResponses = { + /** + * OAuth callback processed successfully */ 200: boolean } -export type ExperimentalConsoleSwitchOrgResponse = - ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] - -export type ToolIdsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/tool/ids" -} - -export type ToolIdsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] - -export type ToolIdsResponses = { - /** - * Tool IDs - */ - 200: ToolIds -} - -export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] - -export type ToolListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - provider: string - model: string - } - url: "/experimental/tool" -} - -export type ToolListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ToolListError = ToolListErrors[keyof ToolListErrors] - -export type ToolListResponses = { - /** - * Tools - */ - 200: ToolList -} - -export type ToolListResponse = ToolListResponses[keyof ToolListResponses] - -export type WorktreeRemoveData = { - body?: WorktreeRemoveInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] - -export type WorktreeRemoveResponses = { - /** - * Worktree removed - */ - 200: boolean -} - -export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] - -export type WorktreeListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeListResponses = { - /** - * List of worktree directories - */ - 200: Array -} - -export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] - -export type WorktreeCreateData = { - body?: WorktreeCreateInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] - -export type WorktreeCreateResponses = { - /** - * Worktree created - */ - 200: Worktree -} - -export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] - -export type WorktreeResetData = { - body?: WorktreeResetInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree/reset" -} - -export type WorktreeResetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] - -export type WorktreeResetResponses = { - /** - * Worktree reset - */ - 200: boolean -} - -export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] - -export type ExperimentalSessionListData = { - body?: never - path?: never - query?: { - /** - * Filter sessions by project directory - */ - directory?: string - workspace?: string - /** - * Only return root sessions (no parentID) - */ - roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ - start?: number - /** - * Return sessions updated before this timestamp (milliseconds since epoch) - */ - cursor?: number - /** - * Filter sessions by title (case-insensitive) - */ - search?: string - /** - * Maximum number of sessions to return - */ - limit?: number - /** - * Include archived sessions (default false) - */ - archived?: boolean | "true" | "false" - } - url: "/experimental/session" -} - -export type ExperimentalSessionListResponses = { - /** - * List of sessions - */ - 200: Array -} - -export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] - -export type ExperimentalResourceListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/resource" -} - -export type ExperimentalResourceListResponses = { - /** - * MCP resources - */ - 200: { - [key: string]: McpResource - } -} - -export type ExperimentalResourceListResponse = - ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] export type SessionListData = { body?: never path?: never query?: { - /** - * Filter sessions by directory - */ directory?: string workspace?: string - /** - * List all sessions for the current project - */ scope?: "project" - /** - * Filter sessions by project-relative path - */ path?: string - /** - * Only return root sessions (no parentID) - */ roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ start?: number - /** - * Filter sessions by title (case-insensitive) - */ search?: string - /** - * Maximum number of sessions to return - */ limit?: number } url: "/session" @@ -3329,6 +5009,12 @@ export type SessionCreateData = { body?: { parentID?: string title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } permission?: PermissionRuleset workspaceID?: string } @@ -3564,169 +5250,6 @@ export type SessionTodoResponses = { export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] -export type SessionInitData = { - body?: { - modelID: string - providerID: string - messageID: string - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/init" -} - -export type SessionInitErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] - -export type SessionInitResponses = { - /** - * 200 - */ - 200: boolean -} - -export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] - -export type SessionForkData = { - body?: { - messageID?: string - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/fork" -} - -export type SessionForkResponses = { - /** - * 200 - */ - 200: Session -} - -export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] - -export type SessionAbortData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/abort" -} - -export type SessionAbortErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] - -export type SessionAbortResponses = { - /** - * Aborted session - */ - 200: boolean -} - -export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] - -export type SessionUnshareData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} - -export type SessionUnshareErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] - -export type SessionUnshareResponses = { - /** - * Successfully unshared session - */ - 200: Session -} - -export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] - -export type SessionShareData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} - -export type SessionShareErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] - -export type SessionShareResponses = { - /** - * Successfully shared session - */ - 200: Session -} - -export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] - export type SessionDiffData = { body?: never path: { @@ -3749,44 +5272,6 @@ export type SessionDiffResponses = { export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] -export type SessionSummarizeData = { - body?: { - providerID: string - modelID: string - auto?: boolean - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/summarize" -} - -export type SessionSummarizeErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] - -export type SessionSummarizeResponses = { - /** - * Summarized session - */ - 200: boolean -} - -export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] - export type SessionMessagesData = { body?: never path: { @@ -3795,9 +5280,6 @@ export type SessionMessagesData = { query?: { directory?: string workspace?: string - /** - * Maximum number of messages to return - */ limit?: number before?: string } @@ -3838,9 +5320,6 @@ export type SessionPromptData = { } agent?: string noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ tools?: { [key: string]: boolean } @@ -3957,21 +5436,42 @@ export type SessionMessageResponses = { export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] -export type PartDeleteData = { - body?: never +export type SessionForkData = { + body?: { + messageID?: string + } path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/fork" } -export type PartDeleteErrors = { +export type SessionForkResponses = { + /** + * 200 + */ + 200: Session +} + +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] + +export type SessionAbortData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/abort" +} + +export type SessionAbortErrors = { /** * Bad request */ @@ -3982,32 +5482,34 @@ export type PartDeleteErrors = { 404: NotFoundError } -export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] -export type PartDeleteResponses = { +export type SessionAbortResponses = { /** - * Successfully deleted part + * Aborted session */ 200: boolean } -export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] -export type PartUpdateData = { - body?: Part +export type SessionInitData = { + body?: { + modelID: string + providerID: string + messageID: string + } path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/init" } -export type PartUpdateErrors = { +export type SessionInitErrors = { /** * Bad request */ @@ -4018,16 +5520,122 @@ export type PartUpdateErrors = { 404: NotFoundError } -export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] -export type PartUpdateResponses = { +export type SessionInitResponses = { /** - * Successfully updated part + * 200 */ - 200: Part + 200: boolean } -export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] + +export type SessionUnshareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionUnshareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] + +export type SessionUnshareResponses = { + /** + * Successfully unshared session + */ + 200: Session +} + +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] + +export type SessionShareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionShareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] + +export type SessionShareResponses = { + /** + * Successfully shared session + */ + 200: Session +} + +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] + +export type SessionSummarizeData = { + body?: { + providerID: string + modelID: string + auto?: boolean + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/summarize" +} + +export type SessionSummarizeErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] + +export type SessionSummarizeResponses = { + /** + * Summarized session + */ + 200: boolean +} + +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] export type SessionPromptAsyncData = { body?: { @@ -4038,9 +5646,6 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ tools?: { [key: string]: boolean } @@ -4286,22 +5891,21 @@ export type PermissionRespondResponses = { export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] -export type PermissionReplyData = { - body?: { - reply: "once" | "always" | "reject" - message?: string - } +export type PartDeleteData = { + body?: never path: { - requestID: string + sessionID: string + messageID: string + partID: string } query?: { directory?: string workspace?: string } - url: "/permission/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type PermissionReplyErrors = { +export type PartDeleteErrors = { /** * Bad request */ @@ -4312,73 +5916,32 @@ export type PermissionReplyErrors = { 404: NotFoundError } -export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] -export type PermissionReplyResponses = { +export type PartDeleteResponses = { /** - * Permission processed successfully + * Successfully deleted part */ 200: boolean } -export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] -export type PermissionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/permission" -} - -export type PermissionListResponses = { - /** - * List of pending permissions - */ - 200: Array -} - -export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] - -export type QuestionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/question" -} - -export type QuestionListResponses = { - /** - * List of pending questions - */ - 200: Array -} - -export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] - -export type QuestionReplyData = { - body?: { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array - } +export type PartUpdateData = { + body?: Part path: { - requestID: string + sessionID: string + messageID: string + partID: string } query?: { directory?: string workspace?: string } - url: "/question/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type QuestionReplyErrors = { +export type PartUpdateErrors = { /** * Bad request */ @@ -4389,182 +5952,16 @@ export type QuestionReplyErrors = { 404: NotFoundError } -export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] -export type QuestionReplyResponses = { +export type PartUpdateResponses = { /** - * Question answered successfully + * Successfully updated part */ - 200: boolean + 200: Part } -export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] - -export type QuestionRejectData = { - body?: never - path: { - requestID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/question/{requestID}/reject" -} - -export type QuestionRejectErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] - -export type QuestionRejectResponses = { - /** - * Question rejected successfully - */ - 200: boolean -} - -export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] - -export type ProviderListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider" -} - -export type ProviderListResponses = { - /** - * List of providers - */ - 200: { - all: Array - default: { - [key: string]: string - } - connected: Array - } -} - -export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] - -export type ProviderAuthData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider/auth" -} - -export type ProviderAuthResponses = { - /** - * Provider auth methods - */ - 200: { - [key: string]: Array - } -} - -export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] - -export type ProviderOauthAuthorizeData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * Prompt inputs - */ - inputs?: { - [key: string]: string - } - } - path: { - /** - * Provider ID - */ - providerID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/authorize" -} - -export type ProviderOauthAuthorizeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] - -export type ProviderOauthAuthorizeResponses = { - /** - * Authorization URL and method - */ - 200: ProviderAuthAuthorization -} - -export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] - -export type ProviderOauthCallbackData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * OAuth authorization code - */ - code?: string - } - path: { - /** - * Provider ID - */ - providerID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/callback" -} - -export type ProviderOauthCallbackErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] - -export type ProviderOauthCallbackResponses = { - /** - * OAuth callback processed successfully - */ - 200: boolean -} - -export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] export type SyncStartData = { body?: never @@ -4664,402 +6061,150 @@ export type SyncHistoryListResponses = { export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] -export type FindTextData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - pattern: string - } - url: "/find" -} - -export type FindTextResponses = { - /** - * Matches - */ - 200: Array<{ - path: { - text: string - } - lines: { - text: string - } - line_number: number - absolute_offset: number - submatches: Array<{ - match: { - text: string - } - start: number - end: number - }> - }> -} - -export type FindTextResponse = FindTextResponses[keyof FindTextResponses] - -export type FindFilesData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - } - url: "/find/file" -} - -export type FindFilesResponses = { - /** - * File paths - */ - 200: Array -} - -export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] - -export type FindSymbolsData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - } - url: "/find/symbol" -} - -export type FindSymbolsResponses = { - /** - * Symbols - */ - 200: Array -} - -export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] - -export type FileListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file" -} - -export type FileListResponses = { - /** - * Files and directories - */ - 200: Array -} - -export type FileListResponse = FileListResponses[keyof FileListResponses] - -export type FileReadData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file/content" -} - -export type FileReadResponses = { - /** - * File content - */ - 200: FileContent -} - -export type FileReadResponse = FileReadResponses[keyof FileReadResponses] - -export type FileStatusData = { +export type V2SessionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/file/status" + url: "/api/session" } -export type FileStatusResponses = { - /** - * File status - */ - 200: Array -} - -export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] - -export type EventSubscribeData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/event" -} - -export type EventSubscribeResponses = { - /** - * Event stream - */ - 200: Event -} - -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] - -export type McpStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} - -export type McpStatusResponses = { - /** - * MCP server status - */ - 200: { - [key: string]: McpStatus - } -} - -export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] - -export type McpAddData = { - body?: { - name: string - config: McpLocalConfig | McpRemoteConfig - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} - -export type McpAddErrors = { +export type V2SessionListErrors = { /** * Bad request */ 400: BadRequestError } -export type McpAddError = McpAddErrors[keyof McpAddErrors] +export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] -export type McpAddResponses = { +export type V2SessionListResponses = { /** - * MCP server added successfully + * V2SessionsResponse */ - 200: { - [key: string]: McpStatus - } + 200: V2SessionsResponse } -export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] -export type McpAuthRemoveData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} - -export type McpAuthRemoveErrors = { - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] - -export type McpAuthRemoveResponses = { - /** - * OAuth credentials removed - */ - 200: { - success: true - } -} - -export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] - -export type McpAuthStartData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} - -export type McpAuthStartErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] - -export type McpAuthStartResponses = { - /** - * OAuth flow started - */ - 200: { - /** - * URL to open in browser for authorization - */ - authorizationUrl: string - } -} - -export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] - -export type McpAuthCallbackData = { +export type V2SessionPromptData = { body?: { - /** - * Authorization code from OAuth callback - */ - code: string + prompt: Prompt + delivery?: SessionDelivery } path: { - name: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/mcp/{name}/auth/callback" + url: "/api/session/{sessionID}/prompt" } -export type McpAuthCallbackErrors = { +export type V2SessionPromptResponses = { + /** + * Session.Message + */ + 200: SessionMessage +} + +export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] + +export type V2SessionCompactData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/compact" +} + +export type V2SessionCompactResponses = { + /** + * + */ + 204: void +} + +export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] + +export type V2SessionWaitData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/wait" +} + +export type V2SessionWaitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] + +export type V2SessionContextData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/context" +} + +export type V2SessionContextResponses = { + /** + * Success + */ + 200: Array +} + +export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] + +export type V2SessionMessagesData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/message" +} + +export type V2SessionMessagesErrors = { /** * Bad request */ 400: BadRequestError +} + +export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] + +export type V2SessionMessagesResponses = { /** - * Not found + * V2SessionMessagesResponse */ - 404: NotFoundError + 200: V2SessionMessagesResponse } -export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] - -export type McpAuthCallbackResponses = { - /** - * OAuth authentication completed - */ - 200: McpStatus -} - -export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] - -export type McpAuthAuthenticateData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth/authenticate" -} - -export type McpAuthAuthenticateErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] - -export type McpAuthAuthenticateResponses = { - /** - * OAuth authentication completed - */ - 200: McpStatus -} - -export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] - -export type McpConnectData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/connect" -} - -export type McpConnectResponses = { - /** - * MCP server connected successfully - */ - 200: boolean -} - -export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] - -export type McpDisconnectData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/disconnect" -} - -export type McpDisconnectResponses = { - /** - * MCP server disconnected successfully - */ - 200: boolean -} - -export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] export type TuiAppendPromptData = { body?: { @@ -5240,9 +6385,6 @@ export type TuiShowToastData = { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } path?: never @@ -5263,7 +6405,7 @@ export type TuiShowToastResponses = { export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 path?: never query?: { directory?: string @@ -5368,179 +6510,206 @@ export type TuiControlResponseResponses = { export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] -export type InstanceDisposeData = { +export type ExperimentalWorkspaceAdapterListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/instance/dispose" + url: "/experimental/workspace/adapter" } -export type InstanceDisposeResponses = { +export type ExperimentalWorkspaceAdapterListResponses = { /** - * Instance disposed + * Workspace adapters + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] + +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + id?: string + type: string + branch: string | null + extra?: unknown | null + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + +export type ExperimentalWorkspaceCreateResponses = { + /** + * Workspace created + */ + 200: Workspace +} + +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + }> +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + +export type ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + +export type ExperimentalWorkspaceRemoveResponses = { + /** + * Workspace removed + */ + 200: Workspace +} + +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + +export type ExperimentalWorkspaceSessionRestoreData = { + body?: { + sessionID: string + } + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}/session-restore" +} + +export type ExperimentalWorkspaceSessionRestoreErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceSessionRestoreError = + ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] + +export type ExperimentalWorkspaceSessionRestoreResponses = { + /** + * Session replay started + */ + 200: { + total: number + } +} + +export type ExperimentalWorkspaceSessionRestoreResponse = + ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] + +export type PtyConnectData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}/connect" +} + +export type PtyConnectErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] + +export type PtyConnectResponses = { + /** + * Connected session */ 200: boolean } -export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] - -export type PathGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/path" -} - -export type PathGetResponses = { - /** - * Path - */ - 200: Path -} - -export type PathGetResponse = PathGetResponses[keyof PathGetResponses] - -export type VcsGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/vcs" -} - -export type VcsGetResponses = { - /** - * VCS info - */ - 200: VcsInfo -} - -export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] - -export type VcsDiffData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - mode: "git" | "branch" - } - url: "/vcs/diff" -} - -export type VcsDiffResponses = { - /** - * VCS diff - */ - 200: Array -} - -export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] - -export type CommandListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/command" -} - -export type CommandListResponses = { - /** - * List of commands - */ - 200: Array -} - -export type CommandListResponse = CommandListResponses[keyof CommandListResponses] - -export type AppAgentsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/agent" -} - -export type AppAgentsResponses = { - /** - * List of agents - */ - 200: Array -} - -export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] - -export type AppSkillsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/skill" -} - -export type AppSkillsResponses = { - /** - * List of skills - */ - 200: Array<{ - name: string - description: string - location: string - content: string - }> -} - -export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] - -export type LspStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/lsp" -} - -export type LspStatusResponses = { - /** - * LSP server status - */ - 200: Array -} - -export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] - -export type FormatterStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/formatter" -} - -export type FormatterStatusResponses = { - /** - * Formatter status - */ - 200: Array -} - -export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 930fd8c92a..6ff18b5155 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1,16 +1,201 @@ { - "openapi": "3.1.1", + "openapi": "3.1.0", "info": { "title": "opencode", - "description": "opencode api", - "version": "1.0.0" + "version": "1.0.0", + "description": "opencode api" }, "paths": { + "/auth/{providerID}": { + "put": { + "tags": ["control"], + "operationId": "auth.set", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully set authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully set authentication credentials" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Set authentication credentials", + "summary": "Set auth credentials", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Auth" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["control"], + "operationId": "auth.remove", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully removed authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully removed authentication credentials" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Remove authentication credentials", + "summary": "Remove auth credentials", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + } + ] + } + }, + "/log": { + "post": { + "tags": ["control"], + "operationId": "app.log", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Log entry written successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Log entry written successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Write a log entry to the server logs with specified level and metadata.", + "summary": "Write log", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name for the log entry" + }, + "level": { + "type": "string", + "enum": ["debug", "info", "error", "warn"], + "description": "Log level" + }, + "message": { + "type": "string", + "description": "Log message" + }, + "extra": { + "type": "object" + } + }, + "required": ["service", "level", "message"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" + } + ] + } + }, "/global/health": { "get": { + "tags": ["global"], "operationId": "global.health", - "summary": "Get health", - "description": "Get health information about the OpenCode server.", + "parameters": [], "responses": { "200": { "description": "Health information", @@ -21,18 +206,22 @@ "properties": { "healthy": { "type": "boolean", - "const": true + "enum": [true] }, "version": { "type": "string" } }, - "required": ["healthy", "version"] + "required": ["healthy", "version"], + "additionalProperties": false, + "description": "Health information" } } } } }, + "description": "Get health information about the OpenCode server.", + "summary": "Get health", "x-codeSamples": [ { "lang": "js", @@ -43,9 +232,9 @@ }, "/global/event": { "get": { + "tags": ["global"], "operationId": "global.event", - "summary": "Get global events", - "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "parameters": [], "responses": { "200": { "description": "Event stream", @@ -58,6 +247,8 @@ } } }, + "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "summary": "Get global events", "x-codeSamples": [ { "lang": "js", @@ -68,9 +259,9 @@ }, "/global/config": { "get": { + "tags": ["global"], "operationId": "global.config.get", - "summary": "Get global configuration", - "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "parameters": [], "responses": { "200": { "description": "Get global config info", @@ -83,6 +274,8 @@ } } }, + "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "summary": "Get global configuration", "x-codeSamples": [ { "lang": "js", @@ -91,9 +284,9 @@ ] }, "patch": { + "tags": ["global"], "operationId": "global.config.update", - "summary": "Update global configuration", - "description": "Update global OpenCode configuration settings and preferences.", + "parameters": [], "responses": { "200": { "description": "Successfully updated global config", @@ -116,6 +309,8 @@ } } }, + "description": "Update global OpenCode configuration settings and preferences.", + "summary": "Update global configuration", "requestBody": { "content": { "application/json": { @@ -135,21 +330,24 @@ }, "/global/dispose": { "post": { + "tags": ["global"], "operationId": "global.dispose", - "summary": "Dispose instance", - "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "parameters": [], "responses": { "200": { "description": "Global disposed", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Global disposed" } } } } }, + "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "summary": "Dispose instance", "x-codeSamples": [ { "lang": "js", @@ -160,9 +358,9 @@ }, "/global/upgrade": { "post": { + "tags": ["global"], "operationId": "global.upgrade", - "summary": "Upgrade opencode", - "description": "Upgrade opencode to the specified version or latest if not specified.", + "parameters": [], "responses": { "200": { "description": "Upgrade result", @@ -175,28 +373,31 @@ "properties": { "success": { "type": "boolean", - "const": true + "enum": [true] }, "version": { "type": "string" } }, - "required": ["success", "version"] + "required": ["success", "version"], + "additionalProperties": false }, { "type": "object", "properties": { "success": { "type": "boolean", - "const": false + "enum": [false] }, "error": { "type": "string" } }, - "required": ["success", "error"] + "required": ["success", "error"], + "additionalProperties": false } - ] + ], + "description": "Upgrade result" } } } @@ -212,6 +413,8 @@ } } }, + "description": "Upgrade opencode to the specified version or latest if not specified.", + "summary": "Upgrade opencode", "requestBody": { "content": { "application/json": { @@ -221,7 +424,8 @@ "target": { "type": "string" } - } + }, + "additionalProperties": false } } } @@ -234,1275 +438,72 @@ ] } }, - "/auth/{providerID}": { - "put": { - "operationId": "auth.set", - "summary": "Set auth credentials", - "description": "Set authentication credentials", - "responses": { - "200": { - "description": "Successfully set authentication credentials", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "parameters": [ - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Auth" - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "auth.remove", - "summary": "Remove auth credentials", - "description": "Remove authentication credentials", - "responses": { - "200": { - "description": "Successfully removed authentication credentials", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "parameters": [ - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" - } - ] - } - }, - "/log": { - "post": { - "operationId": "app.log", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Write log", - "description": "Write a log entry to the server logs with specified level and metadata.", - "responses": { - "200": { - "description": "Log entry written successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "service": { - "description": "Service name for the log entry", - "type": "string" - }, - "level": { - "description": "Log level", - "type": "string", - "enum": ["debug", "info", "error", "warn"] - }, - "message": { - "description": "Log message", - "type": "string" - }, - "extra": { - "description": "Additional metadata for the log entry", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["service", "level", "message"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/adapter": { + "/event": { "get": { - "operationId": "experimental.workspace.adapter.list", + "tags": ["event"], + "operationId": "event.subscribe", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List workspace adapters", - "description": "List all available workspace adapters for the current project.", "responses": { "200": { - "description": "Workspace adapters", + "description": "Event stream", "content": { - "application/json": { + "text/event-stream": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "required": ["type", "name", "description"] - } + "$ref": "#/components/schemas/Event" } } } } }, + "description": "Get events", + "summary": "Subscribe to events", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" - } - ] - } - }, - "/experimental/workspace": { - "post": { - "operationId": "experimental.workspace.create", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Create workspace", - "description": "Create a workspace for the current project.", - "responses": { - "200": { - "description": "Workspace created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - } - }, - "required": ["type", "branch", "extra"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" - } - ] - }, - "get": { - "operationId": "experimental.workspace.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List workspaces", - "description": "List all workspaces.", - "responses": { - "200": { - "description": "Workspaces", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/status": { - "get": { - "operationId": "experimental.workspace.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Workspace status", - "description": "Get connection status for workspaces in the current project.", - "responses": { - "200": { - "description": "Workspace status", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/{id}": { - "delete": { - "operationId": "experimental.workspace.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true - } - ], - "summary": "Remove workspace", - "description": "Remove an existing workspace.", - "responses": { - "200": { - "description": "Workspace removed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/{id}/session-restore": { - "post": { - "operationId": "experimental.workspace.sessionRestore", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true - } - ], - "summary": "Restore session into workspace", - "description": "Replay a session's sync events into the target workspace in batches.", - "responses": { - "200": { - "description": "Session replay started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["total"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" - } - ] - } - }, - "/project": { - "get": { - "operationId": "project.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List all projects", - "description": "Get a list of projects that have been opened with OpenCode.", - "responses": { - "200": { - "description": "List of projects", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Project" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" - } - ] - } - }, - "/project/current": { - "get": { - "operationId": "project.current", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get current project", - "description": "Retrieve the currently active project that OpenCode is working with.", - "responses": { - "200": { - "description": "Current project information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" - } - ] - } - }, - "/project/git/init": { - "post": { - "operationId": "project.initGit", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Initialize git repository", - "description": "Create a git repository for the current project and return the refreshed project info.", - "responses": { - "200": { - "description": "Project information after git initialization", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" - } - ] - } - }, - "/project/{projectID}": { - "patch": { - "operationId": "project.update", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "projectID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Update project", - "description": "Update project properties such as name, icon, and commands.", - "responses": { - "200": { - "description": "Updated project information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } - }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" - } - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" - } - ] - } - }, - "/pty/shells": { - "get": { - "operationId": "pty.shells", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List available shells", - "description": "Get a list of available shells on the system.", - "responses": { - "200": { - "description": "List of shells", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "name": { - "type": "string" - }, - "acceptable": { - "type": "boolean" - } - }, - "required": ["path", "name", "acceptable"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})" - } - ] - } - }, - "/pty": { - "get": { - "operationId": "pty.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List PTY sessions", - "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - "responses": { - "200": { - "description": "List of sessions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pty" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" - } - ] - }, - "post": { - "operationId": "pty.create", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Create PTY session", - "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - "responses": { - "200": { - "description": "Created session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "title": { - "type": "string" - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" - } - ] - } - }, - "/pty/{ptyID}": { - "get": { - "operationId": "pty.get", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Get PTY session", - "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Session info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" - } - ] - }, - "put": { - "operationId": "pty.update", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Update PTY session", - "description": "Update properties of an existing pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "size": { - "type": "object", - "properties": { - "rows": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "cols": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["rows", "cols"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "pty.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Remove PTY session", - "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Session removed", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" - } - ] - } - }, - "/pty/{ptyID}/connect": { - "get": { - "operationId": "pty.connect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Connect to PTY session", - "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - "responses": { - "200": { - "description": "Connected session", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" } ] } }, "/config": { "get": { + "tags": ["config"], "operationId": "config.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get configuration", - "description": "Retrieve the current OpenCode configuration settings and preferences.", "responses": { "200": { "description": "Get config info", @@ -1515,6 +516,8 @@ } } }, + "description": "Retrieve the current OpenCode configuration settings and preferences.", + "summary": "Get configuration", "x-codeSamples": [ { "lang": "js", @@ -1523,25 +526,26 @@ ] }, "patch": { + "tags": ["config"], "operationId": "config.update", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Update configuration", - "description": "Update OpenCode configuration settings and preferences.", "responses": { "200": { "description": "Successfully updated config", @@ -1564,6 +568,8 @@ } } }, + "description": "Update OpenCode configuration settings and preferences.", + "summary": "Update configuration", "requestBody": { "content": { "application/json": { @@ -1583,25 +589,26 @@ }, "/config/providers": { "get": { + "tags": ["config"], "operationId": "config.providers", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List config providers", - "description": "Get a list of all configured AI providers and their default models.", "responses": { "200": { "description": "List of providers", @@ -1618,20 +625,21 @@ }, "default": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } } }, - "required": ["providers", "default"] + "required": ["providers", "default"], + "additionalProperties": false, + "description": "List of providers" } } } } }, + "description": "Get a list of all configured AI providers and their default models.", + "summary": "List config providers", "x-codeSamples": [ { "lang": "js", @@ -1642,25 +650,26 @@ }, "/experimental/console": { "get": { + "tags": ["experimental"], "operationId": "experimental.console.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get active Console provider metadata", - "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", "responses": { "200": { "description": "Active Console provider metadata", @@ -1673,6 +682,8 @@ } } }, + "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", + "summary": "Get active Console provider metadata", "x-codeSamples": [ { "lang": "js", @@ -1683,25 +694,26 @@ }, "/experimental/console/orgs": { "get": { + "tags": ["experimental"], "operationId": "experimental.console.listOrgs", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List switchable Console orgs", - "description": "Get the available Console orgs across logged-in accounts, including the current active org.", "responses": { "200": { "description": "Switchable Console orgs", @@ -1734,16 +746,21 @@ "type": "boolean" } }, - "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"] + "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"], + "additionalProperties": false } } }, - "required": ["orgs"] + "required": ["orgs"], + "additionalProperties": false, + "description": "Switchable Console orgs" } } } } }, + "description": "Get the available Console orgs across logged-in accounts, including the current active org.", + "summary": "List switchable Console orgs", "x-codeSamples": [ { "lang": "js", @@ -1754,37 +771,41 @@ }, "/experimental/console/switch": { "post": { + "tags": ["experimental"], "operationId": "experimental.console.switchOrg", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Switch active Console org", - "description": "Persist a new active Console account/org selection for the current local OpenCode state.", "responses": { "200": { "description": "Switch success", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Switch success" } } } } }, + "description": "Persist a new active Console account/org selection for the current local OpenCode state.", + "summary": "Switch active Console org", "requestBody": { "content": { "application/json": { @@ -1798,7 +819,8 @@ "type": "string" } }, - "required": ["accountID", "orgID"] + "required": ["accountID", "orgID"], + "additionalProperties": false } } } @@ -1811,94 +833,44 @@ ] } }, - "/experimental/tool/ids": { - "get": { - "operationId": "tool.ids", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List tool IDs", - "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", - "responses": { - "200": { - "description": "Tool IDs", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToolIDs" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" - } - ] - } - }, "/experimental/tool": { "get": { + "tags": ["experimental"], "operationId": "tool.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "provider", + "in": "query", "schema": { "type": "string" }, "required": true }, { - "in": "query", "name": "model", + "in": "query", "schema": { "type": "string" }, "required": true } ], - "summary": "List tools", - "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", "responses": { "200": { "description": "Tools", @@ -1921,6 +893,8 @@ } } }, + "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + "summary": "List tools", "x-codeSamples": [ { "lang": "js", @@ -1929,27 +903,128 @@ ] } }, - "/experimental/worktree": { - "post": { - "operationId": "worktree.create", + "/experimental/tool/ids": { + "get": { + "tags": ["experimental"], + "operationId": "tool.ids", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tool IDs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolIDs" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + "summary": "List tool IDs", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" + } + ] + } + }, + "/experimental/worktree": { + "get": { + "tags": ["experimental"], + "operationId": "worktree.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of worktree directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of worktree directories" + } + } + } + } + }, + "description": "List all sandbox worktrees for the current project.", + "summary": "List worktrees", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["experimental"], + "operationId": "worktree.create", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create worktree", - "description": "Create a new git worktree for the current project and run any configured startup scripts.", "responses": { "200": { "description": "Worktree created", @@ -1972,6 +1047,8 @@ } } }, + "description": "Create a new git worktree for the current project and run any configured startup scripts.", + "summary": "Create worktree", "requestBody": { "content": { "application/json": { @@ -1988,75 +1065,35 @@ } ] }, - "get": { - "operationId": "worktree.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List worktrees", - "description": "List all sandbox worktrees for the current project.", - "responses": { - "200": { - "description": "List of worktree directories", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" - } - ] - }, "delete": { + "tags": ["experimental"], "operationId": "worktree.remove", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Remove worktree", - "description": "Remove a git worktree and delete its branch.", "responses": { "200": { "description": "Worktree removed", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Worktree removed" } } } @@ -2072,6 +1109,8 @@ } } }, + "description": "Remove a git worktree and delete its branch.", + "summary": "Remove worktree", "requestBody": { "content": { "application/json": { @@ -2091,32 +1130,34 @@ }, "/experimental/worktree/reset": { "post": { + "tags": ["experimental"], "operationId": "worktree.reset", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Reset worktree", - "description": "Reset a worktree branch to the primary default branch.", "responses": { "200": { "description": "Worktree reset", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Worktree reset" } } } @@ -2132,6 +1173,8 @@ } } }, + "description": "Reset a worktree branch to the primary default branch.", + "summary": "Reset worktree", "requestBody": { "content": { "application/json": { @@ -2151,26 +1194,28 @@ }, "/experimental/session": { "get": { + "tags": ["experimental"], "operationId": "experimental.session.list", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - }, - "description": "Filter sessions by project directory" - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "roots", + "in": "query", "schema": { "anyOf": [ { @@ -2182,43 +1227,43 @@ } ] }, - "description": "Only return root sessions (no parentID)" + "required": false }, { - "in": "query", "name": "start", + "in": "query", "schema": { "type": "number" }, - "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "cursor", + "in": "query", "schema": { "type": "number" }, - "description": "Return sessions updated before this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "search", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by title (case-insensitive)" + "required": false }, { - "in": "query", "name": "limit", + "in": "query", "schema": { "type": "number" }, - "description": "Maximum number of sessions to return" + "required": false }, { - "in": "query", "name": "archived", + "in": "query", "schema": { "anyOf": [ { @@ -2230,11 +1275,9 @@ } ] }, - "description": "Include archived sessions (default false)" + "required": false } ], - "summary": "List sessions", - "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", "responses": { "200": { "description": "List of sessions", @@ -2244,12 +1287,15 @@ "type": "array", "items": { "$ref": "#/components/schemas/GlobalSession" - } + }, + "description": "List of sessions" } } } } }, + "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", + "summary": "List sessions", "x-codeSamples": [ { "lang": "js", @@ -2260,25 +1306,26 @@ }, "/experimental/resource": { "get": { + "tags": ["experimental"], "operationId": "experimental.resource.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get MCP resources", - "description": "Get all available MCP resources from connected servers. Optionally filter by name.", "responses": { "200": { "description": "MCP resources", @@ -2286,17 +1333,17 @@ "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/McpResource" - } + }, + "description": "MCP resources" } } } } }, + "description": "Get all available MCP resources from connected servers. Optionally filter by name.", + "summary": "Get MCP resources", "x-codeSamples": [ { "lang": "js", @@ -2305,45 +1352,2838 @@ ] } }, - "/session": { + "/find": { "get": { - "operationId": "session.list", + "tags": ["file"], + "operationId": "find.text", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - }, - "description": "Filter sessions by directory" - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "pattern", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Matches", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "lines": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "line_number": { + "type": "integer", + "minimum": 0 + }, + "absolute_offset": { + "type": "integer", + "minimum": 0 + }, + "submatches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "match": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["match", "start", "end"], + "additionalProperties": false + } + } + }, + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"], + "additionalProperties": false + }, + "description": "Matches" + } + } + } + } + }, + "description": "Search for text patterns across files in the project using ripgrep.", + "summary": "Find text", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" + } + ] + } + }, + "/find/file": { + "get": { + "tags": ["file"], + "operationId": "find.files", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "dirs", + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "required": false + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": ["file", "directory"] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false + } + ], + "responses": { + "200": { + "description": "File paths", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "File paths" + } + } + } + } + }, + "description": "Search for files or directories by name or pattern in the project directory.", + "summary": "Find files", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" + } + ] + } + }, + "/find/symbol": { + "get": { + "tags": ["file"], + "operationId": "find.symbols", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Symbols", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Symbol" + }, + "description": "Symbols" + } + } + } + } + }, + "description": "Search for workspace symbols like functions, classes, and variables using LSP.", + "summary": "Find symbols", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" + } + ] + } + }, + "/file": { + "get": { + "tags": ["file"], + "operationId": "file.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Files and directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileNode" + }, + "description": "Files and directories" + } + } + } + } + }, + "description": "List files and directories in a specified path.", + "summary": "List files", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" + } + ] + } + }, + "/file/content": { + "get": { + "tags": ["file"], + "operationId": "file.read", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "File content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileContent" + } + } + } + } + }, + "description": "Read the content of a specified file.", + "summary": "Read file", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" + } + ] + } + }, + "/file/status": { + "get": { + "tags": ["file"], + "operationId": "file.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File" + }, + "description": "File status" + } + } + } + } + }, + "description": "Get the git status of all files in the project.", + "summary": "Get file status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" + } + ] + } + }, + "/instance/dispose": { + "post": { + "tags": ["instance"], + "operationId": "instance.dispose", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Instance disposed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Instance disposed" + } + } + } + } + }, + "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", + "summary": "Dispose instance", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" + } + ] + } + }, + "/path": { + "get": { + "tags": ["instance"], + "operationId": "path.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Path" + } + } + } + } + }, + "description": "Retrieve the current working directory and related path information for the OpenCode instance.", + "summary": "Get paths", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" + } + ] + } + }, + "/vcs": { + "get": { + "tags": ["instance"], + "operationId": "vcs.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsInfo" + } + } + } + } + }, + "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", + "summary": "Get VCS info", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" + } + ] + } + }, + "/vcs/diff": { + "get": { + "tags": ["instance"], + "operationId": "vcs.diff", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "schema": { + "type": "string", + "enum": ["git", "branch"] + }, + "required": true + } + ], + "responses": { + "200": { + "description": "VCS diff", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VcsFileDiff" + }, + "description": "VCS diff" + } + } + } + } + }, + "description": "Retrieve the current git diff for the working tree or against the default branch.", + "summary": "Get VCS diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" + } + ] + } + }, + "/command": { + "get": { + "tags": ["instance"], + "operationId": "command.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of commands", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + }, + "description": "List of commands" + } + } + } + } + }, + "description": "Get a list of all available commands in the OpenCode system.", + "summary": "List commands", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" + } + ] + } + }, + "/agent": { + "get": { + "tags": ["instance"], + "operationId": "app.agents", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of agents", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + }, + "description": "List of agents" + } + } + } + } + }, + "description": "Get a list of all available AI agents in the OpenCode system.", + "summary": "List agents", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" + } + ] + } + }, + "/skill": { + "get": { + "tags": ["instance"], + "operationId": "app.skills", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of skills", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "location": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": ["name", "description", "location", "content"], + "additionalProperties": false + }, + "description": "List of skills" + } + } + } + } + }, + "description": "Get a list of all available skills in the OpenCode system.", + "summary": "List skills", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + } + ] + } + }, + "/lsp": { + "get": { + "tags": ["instance"], + "operationId": "lsp.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "LSP server status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LSPStatus" + }, + "description": "LSP server status" + } + } + } + } + }, + "description": "Get LSP server status", + "summary": "Get LSP status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + } + ] + } + }, + "/formatter": { + "get": { + "tags": ["instance"], + "operationId": "formatter.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Formatter status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FormatterStatus" + }, + "description": "Formatter status" + } + } + } + } + }, + "description": "Get formatter status", + "summary": "Get formatter status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + } + ] + } + }, + "/mcp": { + "get": { + "tags": ["mcp"], + "operationId": "mcp.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "MCP server status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + }, + "description": "MCP server status" + } + } + } + } + }, + "description": "Get the status of all Model Context Protocol (MCP) servers.", + "summary": "Get MCP status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" + } + ] + }, + "post": { + "tags": ["mcp"], + "operationId": "mcp.add", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "MCP server added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + }, + "description": "MCP server added successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", + "summary": "Add MCP server", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + } + ] + } + }, + "required": ["name", "config"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.start", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth flow started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string" + }, + "oauthState": { + "type": "string" + } + }, + "required": ["authorizationUrl", "oauthState"], + "additionalProperties": false, + "description": "OAuth flow started" + } + } + } + }, + "400": { + "description": "McpUnsupportedOAuthError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUnsupportedOAuthError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + "summary": "Start MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["mcp"], + "operationId": "mcp.auth.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth credentials removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["success"], + "additionalProperties": false, + "description": "OAuth credentials removed" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Remove OAuth credentials for an MCP server.", + "summary": "Remove MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/callback": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.callback", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + "summary": "Complete MCP OAuth", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + }, + "required": ["code"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/authenticate": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.authenticate", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "McpUnsupportedOAuthError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUnsupportedOAuthError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Start OAuth flow and wait for callback (opens browser).", + "summary": "Authenticate MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/connect": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.connect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "MCP server connected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "MCP server connected successfully" + } + } + } + } + }, + "description": "Connect an MCP server.", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/disconnect": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.disconnect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "MCP server disconnected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "MCP server disconnected successfully" + } + } + } + } + }, + "description": "Disconnect an MCP server.", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + } + ] + } + }, + "/project": { + "get": { + "tags": ["project"], + "operationId": "project.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of projects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + }, + "description": "List of projects" + } + } + } + } + }, + "description": "Get a list of projects that have been opened with OpenCode.", + "summary": "List all projects", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" + } + ] + } + }, + "/project/current": { + "get": { + "tags": ["project"], + "operationId": "project.current", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Current project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "description": "Retrieve the currently active project that OpenCode is working with.", + "summary": "Get current project", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" + } + ] + } + }, + "/project/git/init": { + "post": { + "tags": ["project"], + "operationId": "project.initGit", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Project information after git initialization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "description": "Create a git repository for the current project and return the refreshed project info.", + "summary": "Initialize git repository", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" + } + ] + } + }, + "/project/{projectID}": { + "patch": { + "tags": ["project"], + "operationId": "project.update", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Update project properties such as name, icon, and commands.", + "summary": "Update project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" + } + ] + } + }, + "/pty/shells": { + "get": { + "tags": ["pty"], + "operationId": "pty.shells", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of shells", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "acceptable": { + "type": "boolean" + } + }, + "required": ["path", "name", "acceptable"], + "additionalProperties": false + }, + "description": "List of shells" + } + } + } + } + }, + "description": "Get a list of available shells on the system.", + "summary": "List available shells", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})" + } + ] + } + }, + "/pty": { + "get": { + "tags": ["pty"], + "operationId": "pty.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + }, + "description": "List of sessions" + } + } + } + } + }, + "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + "summary": "List PTY sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["pty"], + "operationId": "pty.create", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Created session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + "summary": "Create PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}": { + "get": { + "tags": ["pty"], + "operationId": "pty.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + "summary": "Get PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" + } + ] + }, + "put": { + "tags": ["pty"], + "operationId": "pty.update", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Update properties of an existing pseudo-terminal (PTY) session.", + "summary": "Update PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "cols": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["rows", "cols"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["pty"], + "operationId": "pty.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session removed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Session removed" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", + "summary": "Remove PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}/connect-token": { + "post": { + "tags": ["pty"], + "operationId": "pty.connectToken", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "WebSocket connect token", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false, + "description": "WebSocket connect token" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Create a short-lived ticket for opening a PTY WebSocket connection.", + "summary": "Create PTY WebSocket token", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connectToken({\n ...\n})" + } + ] + } + }, + "/question": { + "get": { + "tags": ["question"], + "operationId": "question.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of pending questions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionRequest" + }, + "description": "List of pending questions" + } + } + } + } + }, + "description": "Get all pending question requests across all sessions.", + "summary": "List pending questions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reply": { + "post": { + "tags": ["question"], + "operationId": "question.reply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Question answered successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Question answered successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Provide answers to a question request from the AI assistant.", + "summary": "Reply to question request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + }, + "description": "User answers in order of questions (each answer is an array of selected labels)" + } + }, + "required": ["answers"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reject": { + "post": { + "tags": ["question"], + "operationId": "question.reject", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Question rejected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Question rejected successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Reject a question request from the AI assistant.", + "summary": "Reject question request", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" + } + ] + } + }, + "/permission": { + "get": { + "tags": ["permission"], + "operationId": "permission.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of pending permissions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRequest" + }, + "description": "List of pending permissions" + } + } + } + } + }, + "description": "Get all pending permission requests across all sessions.", + "summary": "List pending permissions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + } + ] + } + }, + "/permission/{requestID}/reply": { + "post": { + "tags": ["permission"], + "operationId": "permission.reply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Permission processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Permission processed successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Approve or deny a permission request from the AI assistant.", + "summary": "Respond to permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + }, + "message": { + "type": "string" + } + }, + "required": ["reply"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" + } + ] + } + }, + "/provider": { + "get": { + "tags": ["provider"], + "operationId": "provider.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + }, + "default": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "connected": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["all", "default", "connected"], + "additionalProperties": false, + "description": "List of providers" + } + } + } + } + }, + "description": "Get a list of all available AI providers, including both available and connected ones.", + "summary": "List providers", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" + } + ] + } + }, + "/provider/auth": { + "get": { + "tags": ["provider"], + "operationId": "provider.auth", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Provider auth methods", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderAuthMethod" + } + }, + "description": "Provider auth methods" + } + } + } + } + }, + "description": "Retrieve available authentication methods for all AI providers.", + "summary": "Get provider auth methods", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/authorize": { + "post": { + "tags": ["provider"], + "operationId": "provider.oauth.authorize", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Authorization URL and method", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderAuthAuthorization" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Start the OAuth authorization flow for a provider.", + "summary": "Start OAuth authorization", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "type": "number", + "description": "Auth method index" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["method"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/callback": { + "post": { + "tags": ["provider"], + "operationId": "provider.oauth.callback", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth callback processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "OAuth callback processed successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Handle the OAuth callback from a provider after user authorization.", + "summary": "Handle OAuth callback", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "type": "number", + "description": "Auth method index" + }, + "code": { + "type": "string" + } + }, + "required": ["method"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" + } + ] + } + }, + "/session": { + "get": { + "tags": ["session"], + "operationId": "session.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "scope", + "in": "query", "schema": { "type": "string", "enum": ["project"] }, - "description": "List all sessions for the current project" + "required": false }, { - "in": "query", "name": "path", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by project-relative path" + "required": false }, { - "in": "query", "name": "roots", + "in": "query", "schema": { "anyOf": [ { @@ -2355,35 +4195,33 @@ } ] }, - "description": "Only return root sessions (no parentID)" + "required": false }, { - "in": "query", "name": "start", + "in": "query", "schema": { "type": "number" }, - "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "search", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by title (case-insensitive)" + "required": false }, { - "in": "query", "name": "limit", + "in": "query", "schema": { "type": "number" }, - "description": "Maximum number of sessions to return" + "required": false } ], - "summary": "List sessions", - "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", "responses": { "200": { "description": "List of sessions", @@ -2393,12 +4231,15 @@ "type": "array", "items": { "$ref": "#/components/schemas/Session" - } + }, + "description": "List of sessions" } } } } }, + "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", + "summary": "List sessions", "x-codeSamples": [ { "lang": "js", @@ -2407,25 +4248,26 @@ ] }, "post": { + "tags": ["session"], "operationId": "session.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create session", - "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", "responses": { "200": { "description": "Successfully created session", @@ -2448,6 +4290,8 @@ } } }, + "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + "summary": "Create session", "requestBody": { "content": { "application/json": { @@ -2455,20 +4299,38 @@ "type": "object", "properties": { "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" } - } + }, + "additionalProperties": false } } } @@ -2483,25 +4345,26 @@ }, "/session/status": { "get": { + "tags": ["session"], "operationId": "session.status", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get session status", - "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", "responses": { "200": { "description": "Get session status", @@ -2509,12 +4372,10 @@ "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/SessionStatus" - } + }, + "description": "Get session status" } } } @@ -2530,6 +4391,8 @@ } } }, + "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", + "summary": "Get session status", "x-codeSamples": [ { "lang": "js", @@ -2540,25 +4403,28 @@ }, "/session/{sessionID}": { "get": { + "tags": ["session"], "operationId": "session.get", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2566,9 +4432,6 @@ "required": true } ], - "summary": "Get session", - "description": "Retrieve detailed information about a specific OpenCode session.", - "tags": ["Session"], "responses": { "200": { "description": "Get session", @@ -2601,6 +4464,8 @@ } } }, + "description": "Retrieve detailed information about a specific OpenCode session.", + "summary": "Get session", "x-codeSamples": [ { "lang": "js", @@ -2609,25 +4474,28 @@ ] }, "delete": { + "tags": ["session"], "operationId": "session.delete", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2635,15 +4503,14 @@ "required": true } ], - "summary": "Delete session", - "description": "Delete a session and permanently remove all associated data, including messages and history.", "responses": { "200": { "description": "Successfully deleted session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Successfully deleted session" } } } @@ -2669,6 +4536,8 @@ } } }, + "description": "Delete a session and permanently remove all associated data, including messages and history.", + "summary": "Delete session", "x-codeSamples": [ { "lang": "js", @@ -2677,25 +4546,28 @@ ] }, "patch": { + "tags": ["session"], "operationId": "session.update", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2703,8 +4575,6 @@ "required": true } ], - "summary": "Update session", - "description": "Update properties of an existing session, such as title or other metadata.", "responses": { "200": { "description": "Successfully updated session", @@ -2737,6 +4607,8 @@ } } }, + "description": "Update properties of an existing session, such as title or other metadata.", + "summary": "Update session", "requestBody": { "content": { "application/json": { @@ -2755,9 +4627,11 @@ "archived": { "type": "number" } - } + }, + "additionalProperties": false } - } + }, + "additionalProperties": false } } } @@ -2772,25 +4646,28 @@ }, "/session/{sessionID}/children": { "get": { + "tags": ["session"], "operationId": "session.children", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2798,9 +4675,6 @@ "required": true } ], - "summary": "Get session children", - "tags": ["Session"], - "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { "description": "List of children", @@ -2810,7 +4684,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/Session" - } + }, + "description": "List of children" } } } @@ -2836,6 +4711,8 @@ } } }, + "description": "Retrieve all child sessions that were forked from the specified parent session.", + "summary": "Get session children", "x-codeSamples": [ { "lang": "js", @@ -2846,25 +4723,28 @@ }, "/session/{sessionID}/todo": { "get": { + "tags": ["session"], "operationId": "session.todo", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2872,8 +4752,6 @@ "required": true } ], - "summary": "Get session todos", - "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", "responses": { "200": { "description": "Todo list", @@ -2883,7 +4761,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/Todo" - } + }, + "description": "Todo list" } } } @@ -2909,6 +4788,8 @@ } } }, + "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", + "summary": "Get session todos", "x-codeSamples": [ { "lang": "js", @@ -2917,43 +4798,145 @@ ] } }, - "/session/{sessionID}/init": { - "post": { - "operationId": "session.init", + "/session/{sessionID}/diff": { + "get": { + "tags": ["session"], + "operationId": "session.diff", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" }, "required": true + }, + { + "name": "messageID", + "in": "query", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": false } ], - "summary": "Initialize session", - "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", "responses": { "200": { - "description": "200", + "description": "Successfully retrieved diff", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + }, + "description": "Successfully retrieved diff" + } + } + } + } + }, + "description": "Get the file changes (diff) that resulted from a specific user message in the session.", + "summary": "Get message diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message": { + "get": { + "tags": ["session"], + "operationId": "session.messages", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "required": false + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "List of messages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false + }, + "description": "List of messages" } } } @@ -2979,57 +4962,369 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "modelID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["modelID", "providerID", "messageID"] - } - } - } - }, + "description": "Retrieve all messages in a session, including user prompts and AI responses.", + "summary": "Get session messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" } ] - } - }, - "/session/{sessionID}/fork": { + }, "post": { - "operationId": "session.fork", + "tags": ["session"], + "operationId": "session.prompt", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["info", "parts"], + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Create and send a new message to a session, streaming the AI response.", + "summary": "Send message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "agent": { + "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, + "system": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } + } + }, + "required": ["parts"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message/{messageID}": { + "get": { + "tags": ["session"], + "operationId": "session.message", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false, + "description": "Message" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Retrieve a specific message from a session by its message ID.", + "summary": "Get message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["session"], + "operationId": "session.deleteMessage", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully deleted message", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully deleted message" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Permanently delete a specific message and all of its parts from a session without reverting file changes.", + "summary": "Delete message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/fork": { + "post": { + "tags": ["session"], + "operationId": "session.fork", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3037,8 +5332,6 @@ "required": true } ], - "summary": "Fork session", - "description": "Create a new session by forking an existing session at a specific message point.", "responses": { "200": { "description": "200", @@ -3051,6 +5344,8 @@ } } }, + "description": "Create a new session by forking an existing session at a specific message point.", + "summary": "Fork session", "requestBody": { "content": { "application/json": { @@ -3058,10 +5353,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" } - } + }, + "additionalProperties": false } } } @@ -3076,25 +5371,28 @@ }, "/session/{sessionID}/abort": { "post": { + "tags": ["session"], "operationId": "session.abort", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3102,15 +5400,14 @@ "required": true } ], - "summary": "Abort session", - "description": "Abort an active session and stop any ongoing AI processing or command execution.", "responses": { "200": { "description": "Aborted session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Aborted session" } } } @@ -3136,6 +5433,8 @@ } } }, + "description": "Abort an active session and stop any ongoing AI processing or command execution.", + "summary": "Abort session", "x-codeSamples": [ { "lang": "js", @@ -3144,27 +5443,126 @@ ] } }, - "/session/{sessionID}/share": { + "/session/{sessionID}/init": { "post": { - "operationId": "session.share", + "tags": ["session"], + "operationId": "session.init", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "200" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + "summary": "Initialize session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["modelID", "providerID", "messageID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/share": { + "post": { + "tags": ["session"], + "operationId": "session.share", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3172,8 +5570,6 @@ "required": true } ], - "summary": "Share session", - "description": "Create a shareable link for a session, allowing others to view the conversation.", "responses": { "200": { "description": "Successfully shared session", @@ -3206,6 +5602,8 @@ } } }, + "description": "Create a shareable link for a session, allowing others to view the conversation.", + "summary": "Share session", "x-codeSamples": [ { "lang": "js", @@ -3214,25 +5612,28 @@ ] }, "delete": { + "tags": ["session"], "operationId": "session.unshare", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3240,8 +5641,6 @@ "required": true } ], - "summary": "Unshare session", - "description": "Remove the shareable link for a session, making it private again.", "responses": { "200": { "description": "Successfully unshared session", @@ -3274,6 +5673,8 @@ } } }, + "description": "Remove the shareable link for a session, making it private again.", + "summary": "Unshare session", "x-codeSamples": [ { "lang": "js", @@ -3282,88 +5683,30 @@ ] } }, - "/session/{sessionID}/diff": { - "get": { - "operationId": "session.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "query", - "name": "messageID", - "schema": { - "type": "string", - "pattern": "^msg.*" - } - } - ], - "summary": "Get message diff", - "description": "Get the file changes (diff) that resulted from a specific user message in the session.", - "responses": { - "200": { - "description": "Successfully retrieved diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" - } - ] - } - }, "/session/{sessionID}/summarize": { "post": { + "tags": ["session"], "operationId": "session.summarize", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3371,15 +5714,14 @@ "required": true } ], - "summary": "Summarize session", - "description": "Generate a concise summary of the session using AI compaction to preserve key information.", "responses": { "200": { "description": "Summarized session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Summarized session" } } } @@ -3405,6 +5747,8 @@ } } }, + "description": "Generate a concise summary of the session using AI compaction to preserve key information.", + "summary": "Summarize session", "requestBody": { "content": { "application/json": { @@ -3418,11 +5762,11 @@ "type": "string" }, "auto": { - "default": false, "type": "boolean" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false } } } @@ -3435,127 +5779,30 @@ ] } }, - "/session/{sessionID}/message": { - "get": { - "operationId": "session.messages", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "description": "Maximum number of messages to return" - }, - { - "in": "query", - "name": "before", - "schema": { - "type": "string" - } - } - ], - "summary": "Get session messages", - "description": "Retrieve all messages in a session, including user prompts and AI responses.", - "responses": { - "200": { - "description": "List of messages", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" - } - ] - }, + "/session/{sessionID}/prompt_async": { "post": { - "operationId": "session.prompt", + "tags": ["session"], + "operationId": "session.prompt_async", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3563,30 +5810,9 @@ "required": true } ], - "summary": "Send message", - "description": "Create and send a new message to a session, streaming the AI response.", "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/AssistantMessage" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } + "204": { + "description": "Prompt accepted" }, "400": { "description": "Bad request", @@ -3609,6 +5835,8 @@ } } }, + "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + "summary": "Send async message", "requestBody": { "content": { "application/json": { @@ -3616,8 +5844,7 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "model": { "type": "object", @@ -3629,7 +5856,8 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "agent": { "type": "string" @@ -3638,11 +5866,7 @@ "type": "boolean" }, "tools": { - "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -3676,7 +5900,8 @@ } } }, - "required": ["parts"] + "required": ["parts"], + "additionalProperties": false } } } @@ -3684,53 +5909,190 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" } ] } }, - "/session/{sessionID}/message/{messageID}": { - "get": { - "operationId": "session.message", + "/session/{sessionID}/command": { + "post": { + "tags": ["session"], + "operationId": "session.command", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" }, "required": true + } + ], + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["info", "parts"], + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Send a new command to a session for execution by the AI assistant.", + "summary": "Send command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "command": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + } + } + }, + "required": ["arguments", "command"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/shell": { + "post": { + "tags": ["session"], + "operationId": "session.shell", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } }, { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", "in": "path", - "name": "messageID", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^ses.*" }, "required": true } ], - "summary": "Get message", - "description": "Retrieve a specific message from a session by its message ID.", "responses": { "200": { - "description": "Message", + "description": "Created message", "content": { "application/json": { "schema": { @@ -3746,7 +6108,9 @@ } } }, - "required": ["info", "parts"] + "required": ["info", "parts"], + "additionalProperties": false, + "description": "Created message" } } } @@ -3772,33 +6136,240 @@ } } }, + "description": "Execute a shell command within the session context and return the AI's response.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "command": { + "type": "string" + } + }, + "required": ["agent", "command"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" } ] - }, - "delete": { - "operationId": "session.deleteMessage", + } + }, + "/session/{sessionID}/revert": { + "post": { + "tags": ["session"], + "operationId": "session.revert", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", + "summary": "Revert message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/unrevert": { + "post": { + "tags": ["session"], + "operationId": "session.unrevert", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Restore all previously reverted messages in a session.", + "summary": "Restore reverted messages", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/permissions/{permissionID}": { + "post": { + "tags": ["session"], + "operationId": "permission.respond", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3806,24 +6377,23 @@ "required": true }, { + "name": "permissionID", "in": "path", - "name": "messageID", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^per.*" }, "required": true } ], - "summary": "Delete message", - "description": "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", "responses": { "200": { - "description": "Successfully deleted message", + "description": "Permission processed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Permission processed successfully" } } } @@ -3849,35 +6419,58 @@ } } }, + "description": "Approve or deny a permission request from the AI assistant.", + "summary": "Respond to permission", + "deprecated": true, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["response"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" } ] } }, "/session/{sessionID}/message/{messageID}/part/{partID}": { "delete": { + "tags": ["session"], "operationId": "part.delete", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3885,8 +6478,8 @@ "required": true }, { - "in": "path", "name": "messageID", + "in": "path", "schema": { "type": "string", "pattern": "^msg.*" @@ -3894,8 +6487,8 @@ "required": true }, { - "in": "path", "name": "partID", + "in": "path", "schema": { "type": "string", "pattern": "^prt.*" @@ -3903,14 +6496,14 @@ "required": true } ], - "description": "Delete a part from a message", "responses": { "200": { "description": "Successfully deleted part", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Successfully deleted part" } } } @@ -3936,6 +6529,7 @@ } } }, + "description": "Delete a part from a message.", "x-codeSamples": [ { "lang": "js", @@ -3944,25 +6538,28 @@ ] }, "patch": { + "tags": ["session"], "operationId": "part.update", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3970,8 +6567,8 @@ "required": true }, { - "in": "path", "name": "messageID", + "in": "path", "schema": { "type": "string", "pattern": "^msg.*" @@ -3979,8 +6576,8 @@ "required": true }, { - "in": "path", "name": "partID", + "in": "path", "schema": { "type": "string", "pattern": "^prt.*" @@ -3988,7 +6585,6 @@ "required": true } ], - "description": "Update a part in a message", "responses": { "200": { "description": "Successfully updated part", @@ -4021,6 +6617,7 @@ } } }, + "description": "Update a part in a message.", "requestBody": { "content": { "application/json": { @@ -4038,1305 +6635,43 @@ ] } }, - "/session/{sessionID}/prompt_async": { - "post": { - "operationId": "session.prompt_async", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Send async message", - "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - "responses": { - "204": { - "description": "Prompt accepted" - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "agent": { - "type": "string" - }, - "noReply": { - "type": "boolean" - }, - "tools": { - "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "format": { - "$ref": "#/components/schemas/OutputFormat" - }, - "system": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/TextPartInput" - }, - { - "$ref": "#/components/schemas/FilePartInput" - }, - { - "$ref": "#/components/schemas/AgentPartInput" - }, - { - "$ref": "#/components/schemas/SubtaskPartInput" - } - ] - } - } - }, - "required": ["parts"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/command": { - "post": { - "operationId": "session.command", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Send command", - "description": "Send a new command to a session for execution by the AI assistant.", - "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/AssistantMessage" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "string" - }, - "arguments": { - "type": "string" - }, - "command": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"] - } - } - }, - "required": ["arguments", "command"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/shell": { - "post": { - "operationId": "session.shell", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Run shell command", - "description": "Execute a shell command within the session context and return the AI's response.", - "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "command": { - "type": "string" - } - }, - "required": ["agent", "command"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/revert": { - "post": { - "operationId": "session.revert", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Revert message", - "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["messageID"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/unrevert": { - "post": { - "operationId": "session.unrevert", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Restore reverted messages", - "description": "Restore all previously reverted messages in a session.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/permissions/{permissionID}": { - "post": { - "operationId": "permission.respond", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "path", - "name": "permissionID", - "schema": { - "type": "string", - "pattern": "^per.*" - }, - "required": true - } - ], - "summary": "Respond to permission", - "deprecated": true, - "description": "Approve or deny a permission request from the AI assistant.", - "responses": { - "200": { - "description": "Permission processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "response": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["response"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" - } - ] - } - }, - "/permission/{requestID}/reply": { - "post": { - "operationId": "permission.reply", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^per.*" - }, - "required": true - } - ], - "summary": "Respond to permission request", - "description": "Approve or deny a permission request from the AI assistant.", - "responses": { - "200": { - "description": "Permission processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - }, - "message": { - "type": "string" - } - }, - "required": ["reply"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" - } - ] - } - }, - "/permission": { - "get": { - "operationId": "permission.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List pending permissions", - "description": "Get all pending permission requests across all sessions.", - "responses": { - "200": { - "description": "List of pending permissions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionRequest" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" - } - ] - } - }, - "/question": { - "get": { - "operationId": "question.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List pending questions", - "description": "Get all pending question requests across all sessions.", - "responses": { - "200": { - "description": "List of pending questions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionRequest" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" - } - ] - } - }, - "/question/{requestID}/reply": { - "post": { - "operationId": "question.reply", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^que.*" - }, - "required": true - } - ], - "summary": "Reply to question request", - "description": "Provide answers to a question request from the AI assistant.", - "responses": { - "200": { - "description": "Question answered successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "answers": { - "description": "User answers in order of questions (each answer is an array of selected labels)", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["answers"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" - } - ] - } - }, - "/question/{requestID}/reject": { - "post": { - "operationId": "question.reject", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^que.*" - }, - "required": true - } - ], - "summary": "Reject question request", - "description": "Reject a question request from the AI assistant.", - "responses": { - "200": { - "description": "Question rejected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" - } - ] - } - }, - "/provider": { - "get": { - "operationId": "provider.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List providers", - "description": "Get a list of all available AI providers, including both available and connected ones.", - "responses": { - "200": { - "description": "List of providers", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "all": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Provider" - } - }, - "default": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "connected": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["all", "default", "connected"] - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" - } - ] - } - }, - "/provider/auth": { - "get": { - "operationId": "provider.auth", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get provider auth methods", - "description": "Retrieve available authentication methods for all AI providers.", - "responses": { - "200": { - "description": "Provider auth methods", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderAuthMethod" - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" - } - ] - } - }, - "/provider/{providerID}/oauth/authorize": { - "post": { - "operationId": "provider.oauth.authorize", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true, - "description": "Provider ID" - } - ], - "summary": "OAuth authorize", - "description": "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", - "responses": { - "200": { - "description": "Authorization URL and method", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderAuthAuthorization" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "method": { - "description": "Auth method index", - "type": "number" - }, - "inputs": { - "description": "Prompt inputs", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["method"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" - } - ] - } - }, - "/provider/{providerID}/oauth/callback": { - "post": { - "operationId": "provider.oauth.callback", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true, - "description": "Provider ID" - } - ], - "summary": "OAuth callback", - "description": "Handle the OAuth callback from a provider after user authorization.", - "responses": { - "200": { - "description": "OAuth callback processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "method": { - "description": "Auth method index", - "type": "number" - }, - "code": { - "description": "OAuth authorization code", - "type": "string" - } - }, - "required": ["method"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" - } - ] - } - }, "/sync/start": { "post": { + "tags": ["sync"], "operationId": "sync.start", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Start workspace sync", - "description": "Start sync loops for workspaces in the current project that have active sessions.", "responses": { "200": { "description": "Workspace sync started", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Workspace sync started" } } } } }, + "description": "Start sync loops for workspaces in the current project that have active sessions.", + "summary": "Start workspace sync", "x-codeSamples": [ { "lang": "js", @@ -5347,25 +6682,26 @@ }, "/sync/replay": { "post": { + "tags": ["sync"], "operationId": "sync.replay", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Replay sync events", - "description": "Validate and replay a complete sync event history.", "responses": { "200": { "description": "Replayed sync events", @@ -5378,7 +6714,9 @@ "type": "string" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false, + "description": "Replayed sync events" } } } @@ -5394,6 +6732,8 @@ } } }, + "description": "Validate and replay a complete sync event history.", + "summary": "Replay sync events", "requestBody": { "content": { "application/json": { @@ -5404,8 +6744,8 @@ "type": "string" }, "events": { - "minItems": 1, "type": "array", + "minItems": 1, "items": { "type": "object", "properties": { @@ -5417,25 +6757,22 @@ }, "seq": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "type": { "type": "string" }, "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "aggregateID", "seq", "type", "data"] + "required": ["id", "aggregateID", "seq", "type", "data"], + "additionalProperties": false } } }, - "required": ["directory", "events"] + "required": ["directory", "events"], + "additionalProperties": false } } } @@ -5450,25 +6787,26 @@ }, "/sync/history": { "post": { + "tags": ["sync"], "operationId": "sync.history.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List sync events", - "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", "responses": { "200": { "description": "Sync events", @@ -5486,21 +6824,20 @@ "type": "string" }, "seq": { - "type": "number" + "type": "integer", + "minimum": 0 }, "type": { "type": "string" }, "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "aggregate_id", "seq", "type", "data"] - } + "required": ["id", "aggregate_id", "seq", "type", "data"], + "additionalProperties": false + }, + "description": "Sync events" } } } @@ -5516,18 +6853,16 @@ } } }, + "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + "summary": "List sync events", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } } } @@ -5541,511 +6876,35 @@ ] } }, - "/find": { + "/api/session": { "get": { - "operationId": "find.text", + "tags": ["v2"], + "operationId": "v2.session.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "query", - "name": "pattern", - "schema": { - "type": "string" - }, - "required": true } ], - "summary": "Find text", - "description": "Search for text patterns across files in the project using ripgrep.", "responses": { "200": { - "description": "Matches", + "description": "V2SessionsResponse", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "lines": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "line_number": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "absolute_offset": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "submatches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "match": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["match", "start", "end"] - } - } - }, - "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" - } - ] - } - }, - "/find/file": { - "get": { - "operationId": "find.files", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "dirs", - "schema": { - "type": "string", - "enum": ["true", "false"] - } - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "string", - "enum": ["file", "directory"] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200 - } - } - ], - "summary": "Find files", - "description": "Search for files or directories by name or pattern in the project directory.", - "responses": { - "200": { - "description": "File paths", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" - } - ] - } - }, - "/find/symbol": { - "get": { - "operationId": "find.symbols", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Find symbols", - "description": "Search for workspace symbols like functions, classes, and variables using LSP.", - "responses": { - "200": { - "description": "Symbols", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Symbol" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" - } - ] - } - }, - "/file": { - "get": { - "operationId": "file.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "List files", - "description": "List files and directories in a specified path.", - "responses": { - "200": { - "description": "Files and directories", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileNode" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" - } - ] - } - }, - "/file/content": { - "get": { - "operationId": "file.read", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Read file", - "description": "Read the content of a specified file.", - "responses": { - "200": { - "description": "File content", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileContent" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" - } - ] - } - }, - "/file/status": { - "get": { - "operationId": "file.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get file status", - "description": "Get the git status of all files in the project.", - "responses": { - "200": { - "description": "File status", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/File" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" - } - ] - } - }, - "/event": { - "get": { - "operationId": "event.subscribe", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Subscribe to events", - "description": "Get events", - "responses": { - "200": { - "description": "Event stream", - "content": { - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/Event" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" - } - ] - } - }, - "/mcp": { - "get": { - "operationId": "mcp.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get MCP status", - "description": "Get the status of all Model Context Protocol (MCP) servers.", - "responses": { - "200": { - "description": "MCP server status", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/MCPStatus" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" - } - ] - }, - "post": { - "operationId": "mcp.add", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Add MCP server", - "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", - "responses": { - "200": { - "description": "MCP server added successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/MCPStatus" - } + "$ref": "#/components/schemas/V2SessionsResponse" } } } @@ -6061,27 +6920,76 @@ } } }, + "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + "summary": "List v2 sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.list({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/prompt": { + "post": { + "tags": ["v2"], + "operationId": "v2.session.prompt", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session.Message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMessage" + } + } + } + } + }, + "description": "Create a v2 session message and queue it for the agent loop.", + "summary": "Send v2 message", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "name": { - "type": "string" + "prompt": { + "$ref": "#/components/schemas/Prompt" }, - "config": { - "anyOf": [ - { - "$ref": "#/components/schemas/McpLocalConfig" - }, - { - "$ref": "#/components/schemas/McpRemoteConfig" - } - ] + "delivery": { + "$ref": "#/components/schemas/SessionDelivery" } }, - "required": ["name", "config"] + "required": ["prompt"], + "additionalProperties": false } } } @@ -6089,187 +6997,197 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.prompt({\n ...\n})" } ] } }, - "/mcp/{name}/auth": { + "/api/session/{sessionID}/compact": { "post": { - "operationId": "mcp.auth.start", + "tags": ["v2"], + "operationId": "v2.session.compact", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" - }, + } + }, + { + "name": "sessionID", "in": "path", - "name": "name", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, "required": true } ], - "summary": "Start MCP OAuth", - "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", "responses": { - "200": { - "description": "OAuth flow started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "authorizationUrl": { - "description": "URL to open in browser for authorization", - "type": "string" - } - }, - "required": ["authorizationUrl"] - } - } - } - }, - "400": { - "description": "MCP server does not support OAuth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpUnsupportedOAuthError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } + "204": { + "description": "" } }, + "description": "Compact a v2 session conversation.", + "summary": "Compact v2 session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "mcp.auth.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "name", - "required": true - } - ], - "summary": "Remove MCP OAuth", - "description": "Remove OAuth credentials for an MCP server", - "responses": { - "200": { - "description": "OAuth credentials removed", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "const": true - } - }, - "required": ["success"] - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.compact({\n ...\n})" } ] } }, - "/mcp/{name}/auth/callback": { + "/api/session/{sessionID}/wait": { "post": { - "operationId": "mcp.auth.callback", + "tags": ["v2"], + "operationId": "v2.session.wait", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" - }, + } + }, + { + "name": "sessionID", "in": "path", - "name": "name", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "" + } + }, + "description": "Wait for a v2 session agent loop to become idle.", + "summary": "Wait for v2 session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.wait({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/context": { + "get": { + "tags": ["v2"], + "operationId": "v2.session.context", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, "required": true } ], - "summary": "Complete MCP OAuth", - "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", "responses": { "200": { - "description": "OAuth authentication completed", + "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MCPStatus" + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } + } + } + } + } + }, + "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + "summary": "Get v2 session context", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.context({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/message": { + "get": { + "tags": ["v2 messages"], + "operationId": "v2.session.messages", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "V2SessionMessagesResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2SessionMessagesResponse" } } } @@ -6283,235 +7201,48 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "description": "Authorization code from OAuth callback", - "type": "string" - } - }, - "required": ["code"] - } - } } }, + "description": "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "summary": "Get v2 session messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/auth/authenticate": { - "post": { - "operationId": "mcp.auth.authenticate", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "name", - "required": true - } - ], - "summary": "Authenticate MCP OAuth", - "description": "Start OAuth flow and wait for callback (opens browser)", - "responses": { - "200": { - "description": "OAuth authentication completed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MCPStatus" - } - } - } - }, - "400": { - "description": "MCP server does not support OAuth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpUnsupportedOAuthError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/connect": { - "post": { - "operationId": "mcp.connect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "name", - "schema": { - "type": "string" - }, - "required": true - } - ], - "description": "Connect an MCP server", - "responses": { - "200": { - "description": "MCP server connected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/disconnect": { - "post": { - "operationId": "mcp.disconnect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "name", - "schema": { - "type": "string" - }, - "required": true - } - ], - "description": "Disconnect an MCP server", - "responses": { - "200": { - "description": "MCP server disconnected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.messages({\n ...\n})" } ] } }, "/tui/append-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.appendPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Append TUI prompt", - "description": "Append prompt to the TUI", "responses": { "200": { "description": "Prompt processed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt processed successfully" } } } @@ -6527,6 +7258,8 @@ } } }, + "description": "Append prompt to the TUI.", + "summary": "Append TUI prompt", "requestBody": { "content": { "application/json": { @@ -6537,7 +7270,8 @@ "type": "string" } }, - "required": ["text"] + "required": ["text"], + "additionalProperties": false } } } @@ -6552,37 +7286,41 @@ }, "/tui/open-help": { "post": { + "tags": ["tui"], "operationId": "tui.openHelp", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open help dialog", - "description": "Open the help dialog in the TUI to display user assistance information.", "responses": { "200": { "description": "Help dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Help dialog opened successfully" } } } } }, + "description": "Open the help dialog in the TUI to display user assistance information.", + "summary": "Open help dialog", "x-codeSamples": [ { "lang": "js", @@ -6593,37 +7331,41 @@ }, "/tui/open-sessions": { "post": { + "tags": ["tui"], "operationId": "tui.openSessions", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open sessions dialog", - "description": "Open the session dialog", "responses": { "200": { "description": "Session dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Session dialog opened successfully" } } } } }, + "description": "Open the session dialog.", + "summary": "Open sessions dialog", "x-codeSamples": [ { "lang": "js", @@ -6634,37 +7376,41 @@ }, "/tui/open-themes": { "post": { + "tags": ["tui"], "operationId": "tui.openThemes", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open themes dialog", - "description": "Open the theme dialog", "responses": { "200": { "description": "Theme dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Theme dialog opened successfully" } } } } }, + "description": "Open the theme dialog.", + "summary": "Open themes dialog", "x-codeSamples": [ { "lang": "js", @@ -6675,37 +7421,41 @@ }, "/tui/open-models": { "post": { + "tags": ["tui"], "operationId": "tui.openModels", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open models dialog", - "description": "Open the model dialog", "responses": { "200": { "description": "Model dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Model dialog opened successfully" } } } } }, + "description": "Open the model dialog.", + "summary": "Open models dialog", "x-codeSamples": [ { "lang": "js", @@ -6716,37 +7466,41 @@ }, "/tui/submit-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.submitPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Submit TUI prompt", - "description": "Submit the prompt", "responses": { "200": { "description": "Prompt submitted successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt submitted successfully" } } } } }, + "description": "Submit the prompt.", + "summary": "Submit TUI prompt", "x-codeSamples": [ { "lang": "js", @@ -6757,37 +7511,41 @@ }, "/tui/clear-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.clearPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Clear TUI prompt", - "description": "Clear the prompt", "responses": { "200": { "description": "Prompt cleared successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt cleared successfully" } } } } }, + "description": "Clear the prompt.", + "summary": "Clear TUI prompt", "x-codeSamples": [ { "lang": "js", @@ -6798,32 +7556,34 @@ }, "/tui/execute-command": { "post": { + "tags": ["tui"], "operationId": "tui.executeCommand", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Execute TUI command", - "description": "Execute a TUI command (e.g. agent_cycle)", "responses": { "200": { "description": "Command executed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Command executed successfully" } } } @@ -6839,6 +7599,8 @@ } } }, + "description": "Execute a TUI command.", + "summary": "Execute TUI command", "requestBody": { "content": { "application/json": { @@ -6849,7 +7611,8 @@ "type": "string" } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } } } @@ -6864,37 +7627,41 @@ }, "/tui/show-toast": { "post": { + "tags": ["tui"], "operationId": "tui.showToast", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Show TUI toast", - "description": "Show a toast notification in the TUI", "responses": { "200": { "description": "Toast notification shown successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Toast notification shown successfully" } } } } }, + "description": "Show a toast notification in the TUI.", + "summary": "Show TUI toast", "requestBody": { "content": { "application/json": { @@ -6912,13 +7679,12 @@ "enum": ["info", "success", "warning", "error"] }, "duration": { - "description": "Duration in milliseconds", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["message", "variant"] + "required": ["message", "variant"], + "additionalProperties": false } } } @@ -6933,32 +7699,34 @@ }, "/tui/publish": { "post": { + "tags": ["tui"], "operationId": "tui.publish", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Publish TUI event", - "description": "Publish a TUI event", "responses": { "200": { "description": "Event published successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Event published successfully" } } } @@ -6974,22 +7742,24 @@ } } }, + "description": "Publish a TUI event.", + "summary": "Publish TUI event", "requestBody": { "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/Event.tui.prompt.append" + "$ref": "#/components/schemas/EventTuiPromptAppend" }, { - "$ref": "#/components/schemas/Event.tui.command.execute" + "$ref": "#/components/schemas/EventTuiCommandExecute" }, { - "$ref": "#/components/schemas/Event.tui.toast.show" + "$ref": "#/components/schemas/EventTuiToastShow" }, { - "$ref": "#/components/schemas/Event.tui.session.select" + "$ref": "#/components/schemas/EventTuiSessionSelect" } ] } @@ -7006,32 +7776,34 @@ }, "/tui/select-session": { "post": { + "tags": ["tui"], "operationId": "tui.selectSession", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Select session", - "description": "Navigate the TUI to display the specified session.", "responses": { "200": { "description": "Session selected successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Session selected successfully" } } } @@ -7057,6 +7829,8 @@ } } }, + "description": "Navigate the TUI to display the specified session.", + "summary": "Select session", "requestBody": { "content": { "application/json": { @@ -7064,12 +7838,12 @@ "type": "object", "properties": { "sessionID": { - "description": "Session ID to navigate to", "type": "string", - "pattern": "^ses.*" + "description": "Session ID to navigate to" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false } } } @@ -7084,25 +7858,26 @@ }, "/tui/control/next": { "get": { + "tags": ["tui"], "operationId": "tui.control.next", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get next TUI request", - "description": "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", "responses": { "200": { "description": "Next TUI request", @@ -7116,12 +7891,16 @@ }, "body": {} }, - "required": ["path", "body"] + "required": ["path", "body"], + "additionalProperties": false, + "description": "Next TUI request" } } } } }, + "description": "Retrieve the next TUI request from the queue for processing.", + "summary": "Get next TUI request", "x-codeSamples": [ { "lang": "js", @@ -7132,37 +7911,41 @@ }, "/tui/control/response": { "post": { + "tags": ["tui"], "operationId": "tui.control.response", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Submit TUI response", - "description": "Submit a response to the TUI request queue to complete a pending request.", "responses": { "200": { "description": "Response submitted successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Response submitted successfully" } } } } }, + "description": "Submit a response to the TUI request queue to complete a pending request.", + "summary": "Submit TUI response", "requestBody": { "content": { "application/json": { @@ -7178,294 +7961,31 @@ ] } }, - "/instance/dispose": { - "post": { - "operationId": "instance.dispose", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Dispose instance", - "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", - "responses": { - "200": { - "description": "Instance disposed", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" - } - ] - } - }, - "/path": { + "/experimental/workspace/adapter": { "get": { - "operationId": "path.get", + "tags": ["workspace"], + "operationId": "experimental.workspace.adapter.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get paths", - "description": "Retrieve the current working directory and related path information for the OpenCode instance.", "responses": { "200": { - "description": "Path", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Path" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" - } - ] - } - }, - "/vcs": { - "get": { - "operationId": "vcs.get", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get VCS info", - "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", - "responses": { - "200": { - "description": "VCS info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VcsInfo" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" - } - ] - } - }, - "/vcs/diff": { - "get": { - "operationId": "vcs.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "mode", - "schema": { - "type": "string", - "enum": ["git", "branch"] - }, - "required": true - } - ], - "summary": "Get VCS diff", - "description": "Retrieve the current git diff for the working tree or against the default branch.", - "responses": { - "200": { - "description": "VCS diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/VcsFileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" - } - ] - } - }, - "/command": { - "get": { - "operationId": "command.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List commands", - "description": "Get a list of all available commands in the OpenCode system.", - "responses": { - "200": { - "description": "List of commands", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Command" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" - } - ] - } - }, - "/agent": { - "get": { - "operationId": "app.agents", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List agents", - "description": "Get a list of all available AI agents in the OpenCode system.", - "responses": { - "200": { - "description": "List of agents", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Agent" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" - } - ] - } - }, - "/skill": { - "get": { - "operationId": "app.skills", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List skills", - "description": "Get a list of all available skills in the OpenCode system.", - "responses": { - "200": { - "description": "List of skills", + "description": "Workspace adapters", "content": { "application/json": { "schema": { @@ -7473,118 +7993,452 @@ "items": { "type": "object", "properties": { + "type": { + "type": "string" + }, "name": { "type": "string" }, "description": { "type": "string" - }, - "location": { - "type": "string" - }, - "content": { - "type": "string" } }, - "required": ["name", "description", "location", "content"] - } + "required": ["type", "name", "description"], + "additionalProperties": false + }, + "description": "Workspace adapters" } } } } }, + "description": "List all available workspace adapters for the current project.", + "summary": "List workspace adapters", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" } ] } }, - "/lsp": { + "/experimental/workspace": { "get": { - "operationId": "lsp.status", + "tags": ["workspace"], + "operationId": "experimental.workspace.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get LSP status", - "description": "Get LSP server status", "responses": { "200": { - "description": "LSP server status", + "description": "Workspaces", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/LSPStatus" - } + "$ref": "#/components/schemas/Workspace" + }, + "description": "Workspaces" } } } } }, + "description": "List all workspaces.", + "summary": "List workspaces", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" } ] - } - }, - "/formatter": { - "get": { - "operationId": "formatter.status", + }, + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get formatter status", - "description": "Get formatter status", "responses": { "200": { - "description": "Formatter status", + "description": "Workspace created", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FormatterStatus" - } + "$ref": "#/components/schemas/Workspace" } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Create a workspace for the current project.", + "summary": "Create workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["type", "branch"], + "additionalProperties": false + } + } } }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/status": { + "get": { + "tags": ["workspace"], + "operationId": "experimental.workspace.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Workspace status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + }, + "description": "Workspace status" + } + } + } + } + }, + "description": "Get connection status for workspaces in the current project.", + "summary": "Workspace status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/{id}": { + "delete": { + "tags": ["workspace"], + "operationId": "experimental.workspace.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Workspace removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Remove an existing workspace.", + "summary": "Remove workspace", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/{id}/session-restore": { + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.sessionRestore", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session replay started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["total"], + "additionalProperties": false, + "description": "Session replay started" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Replay a session's sync events into the target workspace in batches.", + "summary": "Restore session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}/connect": { + "get": { + "tags": ["pty"], + "operationId": "pty.connect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Connected session", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Connected session" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + "summary": "Connect to PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" } ] } @@ -7592,215 +8446,314 @@ }, "components": { "schemas": { - "Project": { + "Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, + { + "$ref": "#/components/schemas/EventFileEdited" + }, + { + "$ref": "#/components/schemas/EventFileWatcherUpdated" + }, + { + "$ref": "#/components/schemas/EventLspClientDiagnostics" + }, + { + "$ref": "#/components/schemas/EventLspUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartDelta" + }, + { + "$ref": "#/components/schemas/EventPermissionAsked" + }, + { + "$ref": "#/components/schemas/EventPermissionReplied" + }, + { + "$ref": "#/components/schemas/EventSessionDiff" + }, + { + "$ref": "#/components/schemas/EventSessionError" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdated" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/EventQuestionAsked" + }, + { + "$ref": "#/components/schemas/EventQuestionReplied" + }, + { + "$ref": "#/components/schemas/EventQuestionRejected" + }, + { + "$ref": "#/components/schemas/EventTodoUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionStatus" + }, + { + "$ref": "#/components/schemas/EventSessionIdle" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventMcpToolsChanged" + }, + { + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" + }, + { + "$ref": "#/components/schemas/EventCommandExecuted" + }, + { + "$ref": "#/components/schemas/EventProjectUpdated" + }, + { + "$ref": "#/components/schemas/EventVcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/EventWorkspaceReady" + }, + { + "$ref": "#/components/schemas/EventWorkspaceFailed" + }, + { + "$ref": "#/components/schemas/EventWorkspaceRestore" + }, + { + "$ref": "#/components/schemas/EventWorkspaceStatus" + }, + { + "$ref": "#/components/schemas/EventWorktreeReady" + }, + { + "$ref": "#/components/schemas/EventWorktreeFailed" + }, + { + "$ref": "#/components/schemas/EventPtyCreated" + }, + { + "$ref": "#/components/schemas/EventPtyUpdated" + }, + { + "$ref": "#/components/schemas/EventPtyExited" + }, + { + "$ref": "#/components/schemas/EventPtyDeleted" + }, + { + "$ref": "#/components/schemas/EventMessageUpdated" + }, + { + "$ref": "#/components/schemas/EventMessageRemoved" + }, + { + "$ref": "#/components/schemas/EventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/EventSessionCreated" + }, + { + "$ref": "#/components/schemas/EventSessionUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionDeleted" + }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + } + ] + }, + "OAuth": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "worktree": { - "type": "string" - }, - "vcs": { + "type": { "type": "string", - "const": "git" + "enum": ["oauth"] }, - "name": { + "refresh": { "type": "string" }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } + "access": { + "type": "string" }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" - } - } + "expires": { + "type": "integer", + "minimum": 0 }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "updated": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "initialized": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["created", "updated"] + "accountId": { + "type": "string" }, - "sandboxes": { - "type": "array", - "items": { + "enterpriseUrl": { + "type": "string" + } + }, + "required": ["type", "refresh", "access", "expires"], + "additionalProperties": false + }, + "ApiAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["api"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { "type": "string" } } }, - "required": ["id", "worktree", "time", "sandboxes"] + "required": ["type", "key"], + "additionalProperties": false }, - "Event.project.updated": { + "WellKnownAuth": { "type": "object", "properties": { "type": { "type": "string", - "const": "project.updated" + "enum": ["wellknown"] }, - "properties": { - "$ref": "#/components/schemas/Project" + "key": { + "type": "string" + }, + "token": { + "type": "string" } }, - "required": ["type", "properties"] + "required": ["type", "key", "token"], + "additionalProperties": false }, - "Event.server.connected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.connected" + "Auth": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuth" }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.global.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "global.disposed" + { + "$ref": "#/components/schemas/ApiAuth" }, - "properties": { - "type": "object", - "properties": {} + { + "$ref": "#/components/schemas/WellKnownAuth" } - }, - "required": ["type", "properties"] - }, - "Event.server.instance.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.instance.disposed" - }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"] - } - }, - "required": ["type", "properties"] - }, - "Event.lsp.client.diagnostics": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "lsp.client.diagnostics" - }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"] - } - }, - "required": ["type", "properties"] - }, - "Event.lsp.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "lsp.updated" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.message.part.delta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.part.delta" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "field": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["sessionID", "messageID", "partID", "field", "delta"] - } - }, - "required": ["type", "properties"] + ] }, "PermissionRequest": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^per.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "permission": { "type": "string" @@ -7812,11 +8765,7 @@ } }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "always": { "type": "array", @@ -7828,58 +8777,18 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "callID": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["messageID", "callID"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] - } - }, - "required": ["type", "properties"] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false }, "SnapshotFileDiff": { "type": "object", @@ -7892,53 +8801,26 @@ }, "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"] - }, - "Event.session.diff": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.diff" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "diff": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["sessionID", "diff"] - } - }, - "required": ["type", "properties"] + "required": ["file", "patch", "additions", "deletions"], + "additionalProperties": false }, "ProviderAuthError": { "type": "object", "properties": { "name": { "type": "string", - "const": "ProviderAuthError" + "enum": ["ProviderAuthError"] }, "data": { "type": "object", @@ -7950,17 +8832,19 @@ "type": "string" } }, - "required": ["providerID", "message"] + "required": ["providerID", "message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "UnknownError": { "type": "object", "properties": { "name": { "type": "string", - "const": "UnknownError" + "enum": ["UnknownError"] }, "data": { "type": "object", @@ -7969,31 +8853,34 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "MessageOutputLengthError": { "type": "object", "properties": { "name": { "type": "string", - "const": "MessageOutputLengthError" + "enum": ["MessageOutputLengthError"] }, "data": { "type": "object", "properties": {} } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "MessageAbortedError": { "type": "object", "properties": { "name": { "type": "string", - "const": "MessageAbortedError" + "enum": ["MessageAbortedError"] }, "data": { "type": "object", @@ -8002,17 +8889,19 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "StructuredOutputError": { "type": "object", "properties": { "name": { "type": "string", - "const": "StructuredOutputError" + "enum": ["StructuredOutputError"] }, "data": { "type": "object", @@ -8022,21 +8911,22 @@ }, "retries": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["message", "retries"] + "required": ["message", "retries"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "ContextOverflowError": { "type": "object", "properties": { "name": { "type": "string", - "const": "ContextOverflowError" + "enum": ["ContextOverflowError"] }, "data": { "type": "object", @@ -8048,17 +8938,19 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "APIError": { "type": "object", "properties": { "name": { "type": "string", - "const": "APIError" + "enum": ["APIError"] }, "data": { "type": "object", @@ -8068,17 +8960,13 @@ }, "statusCode": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "isRetryable": { "type": "boolean" }, "responseHeaders": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -8088,331 +8976,96 @@ }, "metadata": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } } }, - "required": ["message", "isRetryable"] + "required": ["message", "isRetryable"], + "additionalProperties": false } }, - "required": ["name", "data"] - }, - "Event.session.error": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.error" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - } - } - } - }, - "required": ["type", "properties"] - }, - "Event.installation.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "installation.updated" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["type", "properties"] - }, - "Event.installation.update-available": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "installation.update-available" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] + "required": ["name", "data"], + "additionalProperties": false }, "QuestionOption": { "type": "object", "properties": { "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" + "type": "string", + "description": "Display text (1-5 words, concise)" }, "description": { - "description": "Explanation of choice", - "type": "string" + "type": "string", + "description": "Explanation of choice" } }, - "required": ["label", "description"] + "required": ["label", "description"], + "additionalProperties": false }, "QuestionInfo": { "type": "object", "properties": { "question": { - "description": "Complete question", - "type": "string" + "type": "string", + "description": "Complete question" }, "header": { - "description": "Very short label (max 30 chars)", - "type": "string" + "type": "string", + "description": "Very short label (max 30 chars)" }, "options": { - "description": "Available choices", "type": "array", "items": { "$ref": "#/components/schemas/QuestionOption" - } + }, + "description": "Available choices" }, "multiple": { - "description": "Allow selecting multiple choices", "type": "boolean" }, "custom": { - "description": "Allow typing a custom answer (default: true)", "type": "boolean" } }, - "required": ["question", "header", "options"] + "required": ["question", "header", "options"], + "additionalProperties": false }, "QuestionTool": { "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "callID": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["messageID", "callID"], + "additionalProperties": false }, "QuestionRequest": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "questions": { - "description": "Questions to ask", "type": "array", "items": { "$ref": "#/components/schemas/QuestionInfo" - } + }, + "description": "Questions to ask" }, "tool": { "$ref": "#/components/schemas/QuestionTool" } }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["type", "properties"] + "required": ["id", "sessionID", "questions"], + "additionalProperties": false }, "QuestionAnswer": { "type": "array", @@ -8424,12 +9077,10 @@ "type": "object", "properties": { "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "requestID": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, "answers": { "type": "array", @@ -8438,91 +9089,40 @@ } } }, - "required": ["sessionID", "requestID", "answers"] - }, - "Event.question.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "$ref": "#/components/schemas/QuestionReplied" - } - }, - "required": ["type", "properties"] + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false }, "QuestionRejected": { "type": "object", "properties": { "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "requestID": { - "type": "string", - "pattern": "^que.*" + "type": "string" } }, - "required": ["sessionID", "requestID"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRejected" - } - }, - "required": ["type", "properties"] + "required": ["sessionID", "requestID"], + "additionalProperties": false }, "Todo": { "type": "object", "properties": { "content": { - "description": "Brief description of the task", - "type": "string" + "type": "string", + "description": "Brief description of the task" }, "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" }, "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "type": { "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] + "description": "Priority level of the task: high, medium, low" } }, - "required": ["type", "properties"] + "required": ["content", "status", "priority"], + "additionalProperties": false }, "SessionStatus": { "anyOf": [ @@ -8531,115 +9131,56 @@ "properties": { "type": { "type": "string", - "const": "idle" + "enum": ["idle"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", - "const": "retry" + "enum": ["retry"] }, "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "message": { "type": "string" }, "next": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["type", "attempt", "message", "next"] + "required": ["type", "attempt", "message", "next"], + "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", - "const": "busy" + "enum": ["busy"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false } ] }, - "Event.session.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.compacted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.compacted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, "Event.tui.prompt.append": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.prompt.append" + "enum": ["tui.prompt.append"] }, "properties": { "type": "object", @@ -8648,17 +9189,22 @@ "type": "string" } }, - "required": ["text"] + "required": ["text"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.command.execute": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.command.execute" + "enum": ["tui.command.execute"] }, "properties": { "type": "object", @@ -8692,17 +9238,22 @@ ] } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.toast.show": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.toast.show" + "enum": ["tui.toast.show"] }, "properties": { "type": "object", @@ -8718,174 +9269,117 @@ "enum": ["info", "success", "warning", "error"] }, "duration": { - "description": "Duration in milliseconds", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["message", "variant"] + "required": ["message", "variant"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.session.select": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.session.select" + "enum": ["tui.session.select"] }, "properties": { "type": "object", "properties": { "sessionID": { - "description": "Session ID to navigate to", "type": "string", - "pattern": "^ses.*" + "description": "Session ID to navigate to" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "Event.mcp.tools.changed": { + "Project": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "mcp.tools.changed" + "id": { + "type": "string" }, - "properties": { + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "enum": ["git"] + }, + "name": { + "type": "string" + }, + "icon": { "type": "object", "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.browser.open.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp.browser.open.failed" - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, "url": { "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" } }, - "required": ["mcpName", "url"] - } - }, - "required": ["type", "properties"] - }, - "Event.command.executed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "command.executed" + "additionalProperties": false }, - "properties": { + "commands": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "sessionID": { + "start": { "type": "string", - "pattern": "^ses.*" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "description": "Startup script to run when creating a new workspace (worktree)" } }, - "required": ["name", "sessionID", "arguments", "messageID"] - } - }, - "required": ["type", "properties"] - }, - "Event.vcs.branch.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "vcs.branch.updated" + "additionalProperties": false }, - "properties": { + "time": { "type": "object", "properties": { - "branch": { - "type": "string" + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" } } }, - "required": ["type", "properties"] - }, - "Event.worktree.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "worktree.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "branch": { - "type": "string" - } - }, - "required": ["name", "branch"] - } - }, - "required": ["type", "properties"] - }, - "Event.worktree.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "worktree.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false }, "Pty": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^pty.*" + "type": "string" }, "title": { "type": "string" @@ -8908,130 +9402,43 @@ }, "pid": { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"] - }, - "Event.pty.created": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.created" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.updated" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.exited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.exited" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - }, - "exitCode": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["id", "exitCode"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.deleted" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - } - }, - "required": ["id"] - } - }, - "required": ["type", "properties"] + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], + "additionalProperties": false }, "OutputFormatText": { "type": "object", "properties": { "type": { "type": "string", - "const": "text" + "enum": ["text"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false }, "JSONSchema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "OutputFormatJsonSchema": { "type": "object", "properties": { "type": { "type": "string", - "const": "json_schema" + "enum": ["json_schema"] }, "schema": { "$ref": "#/components/schemas/JSONSchema" }, "retryCount": { - "default": 2, "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["type", "schema"] + "required": ["type", "schema"], + "additionalProperties": false }, "OutputFormat": { "anyOf": [ @@ -9047,27 +9454,25 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "role": { "type": "string", - "const": "user" + "enum": ["user"] }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -9088,7 +9493,8 @@ } } }, - "required": ["diffs"] + "required": ["diffs"], + "additionalProperties": false }, "agent": { "type": "string" @@ -9106,53 +9512,49 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "system": { "type": "string" }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } } }, - "required": ["id", "sessionID", "role", "time", "agent", "model"] + "required": ["id", "sessionID", "role", "time", "agent", "model"], + "additionalProperties": false }, "AssistantMessage": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "role": { "type": "string", - "const": "assistant" + "enum": ["assistant"] }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "completed": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false }, "error": { "anyOf": [ @@ -9180,8 +9582,7 @@ ] }, "parentID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "modelID": { "type": "string" @@ -9205,7 +9606,8 @@ "type": "string" } }, - "required": ["cwd", "root"] + "required": ["cwd", "root"], + "additionalProperties": false }, "summary": { "type": "boolean" @@ -9218,42 +9620,38 @@ "properties": { "total": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "input": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "output": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reasoning": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "write": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false }, "structured": {}, "variant": { @@ -9276,7 +9674,8 @@ "path", "cost", "tokens" - ] + ], + "additionalProperties": false }, "Message": { "anyOf": [ @@ -9288,71 +9687,21 @@ } ] }, - "Event.message.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "properties"] - }, - "Event.message.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.removed" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["sessionID", "messageID"] - } - }, - "required": ["type", "properties"] - }, "TextPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "text" + "enum": ["text"] }, "text": { "type": "string" @@ -9368,45 +9717,38 @@ "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "text"] + "required": ["id", "sessionID", "messageID", "type", "text"], + "additionalProperties": false }, "SubtaskPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "subtask" + "enum": ["subtask"] }, "prompt": { "type": "string" @@ -9427,61 +9769,56 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "command": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"], + "additionalProperties": false }, "ReasoningPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "reasoning" + "enum": ["reasoning"] }, "text": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "text", "time"] + "required": ["id", "sessionID", "messageID", "type", "text", "time"], + "additionalProperties": false }, "FilePartSourceText": { "type": "object", @@ -9491,16 +9828,15 @@ }, "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["value", "start", "end"] + "required": ["value", "start", "end"], + "additionalProperties": false }, "FileSource": { "type": "object", @@ -9510,13 +9846,14 @@ }, "type": { "type": "string", - "const": "file" + "enum": ["file"] }, "path": { "type": "string" } }, - "required": ["text", "type", "path"] + "required": ["text", "type", "path"], + "additionalProperties": false }, "Range": { "type": "object", @@ -9526,35 +9863,34 @@ "properties": { "line": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "character": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["line", "character"] + "required": ["line", "character"], + "additionalProperties": false }, "end": { "type": "object", "properties": { "line": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "character": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["line", "character"] + "required": ["line", "character"], + "additionalProperties": false } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false }, "SymbolSource": { "type": "object", @@ -9564,7 +9900,7 @@ }, "type": { "type": "string", - "const": "symbol" + "enum": ["symbol"] }, "path": { "type": "string" @@ -9577,11 +9913,11 @@ }, "kind": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["text", "type", "path", "range", "name", "kind"] + "required": ["text", "type", "path", "range", "name", "kind"], + "additionalProperties": false }, "ResourceSource": { "type": "object", @@ -9591,7 +9927,7 @@ }, "type": { "type": "string", - "const": "resource" + "enum": ["resource"] }, "clientName": { "type": "string" @@ -9600,7 +9936,8 @@ "type": "string" } }, - "required": ["text", "type", "clientName", "uri"] + "required": ["text", "type", "clientName", "uri"], + "additionalProperties": false }, "FilePartSource": { "anyOf": [ @@ -9619,20 +9956,17 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "file" + "enum": ["file"] }, "mime": { "type": "string" @@ -9647,79 +9981,66 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["id", "sessionID", "messageID", "type", "mime", "url"] + "required": ["id", "sessionID", "messageID", "type", "mime", "url"], + "additionalProperties": false }, "ToolStatePending": { "type": "object", "properties": { "status": { "type": "string", - "const": "pending" + "enum": ["pending"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "raw": { "type": "string" } }, - "required": ["status", "input", "raw"] + "required": ["status", "input", "raw"], + "additionalProperties": false }, "ToolStateRunning": { "type": "object", "properties": { "status": { "type": "string", - "const": "running" + "enum": ["running"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "title": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false } }, - "required": ["status", "input", "time"] + "required": ["status", "input", "time"], + "additionalProperties": false }, "ToolStateCompleted": { "type": "object", "properties": { "status": { "type": "string", - "const": "completed" + "enum": ["completed"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "output": { "type": "string" @@ -9728,32 +10049,26 @@ "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacted": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false }, "attachments": { "type": "array", @@ -9762,50 +10077,43 @@ } } }, - "required": ["status", "input", "output", "title", "metadata", "time"] + "required": ["status", "input", "output", "title", "metadata", "time"], + "additionalProperties": false }, "ToolStateError": { "type": "object", "properties": { "status": { "type": "string", - "const": "error" + "enum": ["error"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "error": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false } }, - "required": ["status", "input", "error", "time"] + "required": ["status", "input", "error", "time"], + "additionalProperties": false }, "ToolState": { "anyOf": [ @@ -9827,20 +10135,17 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "tool" + "enum": ["tool"] }, "callID": { "type": "string" @@ -9852,58 +10157,50 @@ "$ref": "#/components/schemas/ToolState" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"], + "additionalProperties": false }, "StepStartPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "step-start" + "enum": ["step-start"] }, "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type"] + "required": ["id", "sessionID", "messageID", "type"], + "additionalProperties": false }, "StepFinishPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "step-finish" + "enum": ["step-finish"] }, "reason": { "type": "string" @@ -9919,89 +10216,81 @@ "properties": { "total": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "input": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "output": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reasoning": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "write": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"], + "additionalProperties": false }, "SnapshotPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "snapshot" + "enum": ["snapshot"] }, "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "snapshot"] + "required": ["id", "sessionID", "messageID", "type", "snapshot"], + "additionalProperties": false }, "PatchPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "patch" + "enum": ["patch"] }, "hash": { "type": "string" @@ -10013,26 +10302,24 @@ } } }, - "required": ["id", "sessionID", "messageID", "type", "hash", "files"] + "required": ["id", "sessionID", "messageID", "type", "hash", "files"], + "additionalProperties": false }, "AgentPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "agent" + "enum": ["agent"] }, "name": { "type": "string" @@ -10045,43 +10332,39 @@ }, "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["value", "start", "end"] + "required": ["value", "start", "end"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "name"] + "required": ["id", "sessionID", "messageID", "type", "name"], + "additionalProperties": false }, "RetryPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "retry" + "enum": ["retry"] }, "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "error": { "$ref": "#/components/schemas/APIError" @@ -10091,33 +10374,31 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"], + "additionalProperties": false }, "CompactionPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "compaction" + "enum": ["compaction"] }, "auto": { "type": "boolean" @@ -10126,11 +10407,11 @@ "type": "boolean" }, "tail_start_id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "auto"] + "required": ["id", "sessionID", "messageID", "type", "auto"], + "additionalProperties": false }, "Part": { "anyOf": [ @@ -10172,62 +10453,6 @@ } ] }, - "Event.message.part.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.part.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["sessionID", "part", "time"] - } - }, - "required": ["type", "properties"] - }, - "Event.message.part.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.part.removed" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["sessionID", "messageID", "partID"] - } - }, - "required": ["type", "properties"] - }, "PermissionAction": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -10245,7 +10470,8 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": ["permission", "pattern", "action"] + "required": ["permission", "pattern", "action"], + "additionalProperties": false }, "PermissionRuleset": { "type": "array", @@ -10257,8 +10483,7 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "slug": { "type": "string" @@ -10267,8 +10492,7 @@ "type": "string" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" }, "directory": { "type": "string" @@ -10277,26 +10501,22 @@ "type": "string" }, "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "summary": { "type": "object", "properties": { "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "files": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "diffs": { "type": "array", @@ -10305,7 +10525,8 @@ } } }, - "required": ["additions", "deletions", "files"] + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, "share": { "type": "object", @@ -10314,11 +10535,31 @@ "type": "string" } }, - "required": ["url"] + "required": ["url"], + "additionalProperties": false }, "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "version": { "type": "string" }, @@ -10327,24 +10568,22 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacting": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "archived": { "type": "number" } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -10353,12 +10592,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "partID": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "snapshot": { "type": "string" @@ -10367,581 +10604,34 @@ "type": "string" } }, - "required": ["messageID"] + "required": ["messageID"], + "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false }, - "Event.session.created": { + "Prompt": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "session.created" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.deleted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "properties"] - }, - "SyncEvent.message.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.updated.1" - }, - "id": { + "text": { "type": "string" }, - "seq": { - "type": "number" + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"] + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.removed.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["sessionID", "messageID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.part.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.part.updated.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["sessionID", "part", "time"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.part.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.part.removed.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["sessionID", "messageID", "partID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.created": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.created.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.updated.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "projectID": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "workspaceID": { - "anyOf": [ - { - "type": "string", - "pattern": "^wrk.*" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "parentID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "additions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "deletions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "files": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"] - }, - { - "type": "null" - } - ] - }, - "share": { - "type": "object", - "properties": { - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - } - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "updated": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "compacting": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "archived": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - } - }, - "permission": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionRuleset" - }, - { - "type": "null" - } - ] - }, - "revert": { - "anyOf": [ - { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"] - }, - { - "type": "null" - } - ] - } - } - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.deleted.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["text"], + "additionalProperties": false }, "GlobalEvent": { "type": "object", @@ -10958,82 +10648,61 @@ "payload": { "anyOf": [ { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/EventServerInstanceDisposed" }, { - "$ref": "#/components/schemas/Event.server.connected" + "$ref": "#/components/schemas/EventFileEdited" }, { - "$ref": "#/components/schemas/Event.global.disposed" + "$ref": "#/components/schemas/EventFileWatcherUpdated" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" + "$ref": "#/components/schemas/EventLspUpdated" }, { - "$ref": "#/components/schemas/Event.lsp.updated" + "$ref": "#/components/schemas/EventMessagePartDelta" }, { - "$ref": "#/components/schemas/Event.message.part.delta" + "$ref": "#/components/schemas/EventPermissionAsked" }, { - "$ref": "#/components/schemas/Event.permission.asked" + "$ref": "#/components/schemas/EventPermissionReplied" }, { - "$ref": "#/components/schemas/Event.permission.replied" + "$ref": "#/components/schemas/EventSessionDiff" }, { - "$ref": "#/components/schemas/Event.session.diff" + "$ref": "#/components/schemas/EventSessionError" }, { - "$ref": "#/components/schemas/Event.session.error" + "$ref": "#/components/schemas/EventInstallationUpdated" }, { - "$ref": "#/components/schemas/Event.installation.updated" + "$ref": "#/components/schemas/EventInstallationUpdate-available" }, { - "$ref": "#/components/schemas/Event.installation.update-available" + "$ref": "#/components/schemas/EventQuestionAsked" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/EventQuestionReplied" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/EventQuestionRejected" }, { - "$ref": "#/components/schemas/Event.workspace.restore" + "$ref": "#/components/schemas/EventTodoUpdated" }, { - "$ref": "#/components/schemas/Event.workspace.status" + "$ref": "#/components/schemas/EventSessionStatus" }, { - "$ref": "#/components/schemas/Event.file.edited" + "$ref": "#/components/schemas/EventSessionIdle" }, { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/EventSessionCompacted" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" @@ -11048,117 +10717,290 @@ "$ref": "#/components/schemas/Event.tui.session.select" }, { - "$ref": "#/components/schemas/Event.mcp.tools.changed" + "$ref": "#/components/schemas/EventMcpToolsChanged" }, { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" }, { - "$ref": "#/components/schemas/Event.command.executed" + "$ref": "#/components/schemas/EventCommandExecuted" }, { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/Event.worktree.ready" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/Event.worktree.failed" + "$ref": "#/components/schemas/EventWorkspaceReady" }, { - "$ref": "#/components/schemas/Event.pty.created" + "$ref": "#/components/schemas/EventWorkspaceFailed" }, { - "$ref": "#/components/schemas/Event.pty.updated" + "$ref": "#/components/schemas/EventWorkspaceRestore" }, { - "$ref": "#/components/schemas/Event.pty.exited" + "$ref": "#/components/schemas/EventWorkspaceStatus" }, { - "$ref": "#/components/schemas/Event.pty.deleted" + "$ref": "#/components/schemas/EventWorktreeReady" }, { - "$ref": "#/components/schemas/Event.message.updated" + "$ref": "#/components/schemas/EventWorktreeFailed" }, { - "$ref": "#/components/schemas/Event.message.removed" + "$ref": "#/components/schemas/EventPtyCreated" }, { - "$ref": "#/components/schemas/Event.message.part.updated" + "$ref": "#/components/schemas/EventPtyUpdated" }, { - "$ref": "#/components/schemas/Event.message.part.removed" + "$ref": "#/components/schemas/EventPtyExited" }, { - "$ref": "#/components/schemas/Event.session.created" + "$ref": "#/components/schemas/EventPtyDeleted" }, { - "$ref": "#/components/schemas/Event.session.updated" + "$ref": "#/components/schemas/EventMessageUpdated" }, { - "$ref": "#/components/schemas/Event.session.deleted" + "$ref": "#/components/schemas/EventMessageRemoved" }, { - "$ref": "#/components/schemas/SyncEvent.message.updated" + "$ref": "#/components/schemas/EventMessagePartUpdated" }, { - "$ref": "#/components/schemas/SyncEvent.message.removed" + "$ref": "#/components/schemas/EventMessagePartRemoved" }, { - "$ref": "#/components/schemas/SyncEvent.message.part.updated" + "$ref": "#/components/schemas/EventSessionCreated" }, { - "$ref": "#/components/schemas/SyncEvent.message.part.removed" + "$ref": "#/components/schemas/EventSessionUpdated" }, { - "$ref": "#/components/schemas/SyncEvent.session.created" + "$ref": "#/components/schemas/EventSessionDeleted" }, { - "$ref": "#/components/schemas/SyncEvent.session.updated" + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, { - "$ref": "#/components/schemas/SyncEvent.session.deleted" + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + }, + { + "$ref": "#/components/schemas/SyncEventMessageUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventMessageRemoved" + }, + { + "$ref": "#/components/schemas/SyncEventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/SyncEventSessionCreated" + }, + { + "$ref": "#/components/schemas/SyncEventSessionUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventSessionDeleted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" } ] } }, - "required": ["directory", "payload"] + "required": ["directory", "payload"], + "additionalProperties": false }, "LogLevel": { - "description": "Log level", "type": "string", - "enum": ["DEBUG", "INFO", "WARN", "ERROR"] + "enum": ["DEBUG", "INFO", "WARN", "ERROR"], + "description": "Log level" }, "ServerConfig": { - "description": "Server configuration for opencode serve and web commands", "type": "object", "properties": { "port": { - "description": "Port to listen on", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "hostname": { - "description": "Hostname to listen on", "type": "string" }, "mdns": { - "description": "Enable mDNS service discovery", "type": "boolean" }, "mdnsDomain": { - "description": "Custom domain name for mDNS service (default: opencode.local)", "type": "string" }, "cors": { - "description": "Additional domains to allow for CORS", "type": "array", "items": { "type": "string" } } - } + }, + "additionalProperties": false, + "description": "Server configuration for opencode serve and web commands" }, "PermissionActionConfig": { "type": "string", @@ -11166,9 +11008,6 @@ }, "PermissionObjectConfig": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/PermissionActionConfig" } @@ -11250,7 +11089,6 @@ "type": "string" }, "variant": { - "description": "Default model variant for this agent (applies only when using the agent's configured model).", "type": "string" }, "temperature": { @@ -11263,11 +11101,7 @@ "type": "string" }, "tools": { - "description": "@deprecated Use 'permission' field instead", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -11276,7 +11110,6 @@ "type": "boolean" }, "description": { - "description": "Description of when to use the agent", "type": "string" }, "mode": { @@ -11284,18 +11117,12 @@ "enum": ["subagent", "primary", "all"] }, "hidden": { - "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", "type": "boolean" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "color": { - "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", "anyOf": [ { "type": "string", @@ -11305,19 +11132,16 @@ "type": "string", "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] } - ] + ], + "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)" }, "steps": { - "description": "Maximum number of agentic iterations before forcing text-only response", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "maxSteps": { - "description": "@deprecated Use 'steps' field instead.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "permission": { "$ref": "#/components/schemas/PermissionConfig" @@ -11368,41 +11192,33 @@ "type": "string" }, "enterpriseUrl": { - "description": "GitHub Enterprise URL for copilot authentication", "type": "string" }, "setCacheKey": { - "description": "Enable promptCacheKey for this provider (default false)", "type": "boolean" }, "timeout": { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "anyOf": [ { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, { "type": "boolean", - "const": false + "enum": [false] } - ] + ], + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout." }, "chunkTimeout": { - "description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, "additionalProperties": {} }, "models": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -11434,7 +11250,7 @@ "anyOf": [ { "type": "boolean", - "const": true + "enum": [true] }, { "type": "object", @@ -11444,7 +11260,8 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"] + "required": ["field"], + "additionalProperties": false } ] }, @@ -11479,10 +11296,12 @@ "type": "number" } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false }, "limit": { "type": "object", @@ -11497,7 +11316,8 @@ "type": "number" } }, - "required": ["context", "output"] + "required": ["context", "output"], + "additionalProperties": false }, "modalities": { "type": "object", @@ -11517,7 +11337,8 @@ } } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false }, "experimental": { "type": "boolean" @@ -11535,166 +11356,141 @@ "api": { "type": "string" } - } + }, + "additionalProperties": false }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "headers": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "variants": { - "description": "Variant-specific configuration", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { "disabled": { - "description": "Disable this variant for the model", "type": "boolean" } }, "additionalProperties": {} - } + }, + "description": "Variant-specific configuration" } - } + }, + "additionalProperties": false } } - } + }, + "additionalProperties": false }, "McpLocalConfig": { "type": "object", "properties": { "type": { - "description": "Type of MCP server connection", "type": "string", - "const": "local" + "enum": ["local"], + "description": "Type of MCP server connection" }, "command": { - "description": "Command and arguments to run the MCP server", "type": "array", "items": { "type": "string" - } + }, + "description": "Command and arguments to run the MCP server" }, "environment": { - "description": "Environment variables to set when running the MCP server", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "enabled": { - "description": "Enable or disable the MCP server on startup", "type": "boolean" }, "timeout": { - "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["type", "command"] + "required": ["type", "command"], + "additionalProperties": false }, "McpOAuthConfig": { "type": "object", "properties": { "clientId": { - "description": "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", "type": "string" }, "clientSecret": { - "description": "OAuth client secret (if required by the authorization server)", "type": "string" }, "scope": { - "description": "OAuth scopes to request during authorization", "type": "string" }, "redirectUri": { - "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", "type": "string" } - } + }, + "additionalProperties": false }, "McpRemoteConfig": { "type": "object", "properties": { "type": { - "description": "Type of MCP server connection", "type": "string", - "const": "remote" + "enum": ["remote"], + "description": "Type of MCP server connection" }, "url": { - "description": "URL of the remote MCP server", - "type": "string" + "type": "string", + "description": "URL of the remote MCP server" }, "enabled": { - "description": "Enable or disable the MCP server on startup", "type": "boolean" }, "headers": { - "description": "Headers to send with the request", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "oauth": { - "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", "anyOf": [ { "$ref": "#/components/schemas/McpOAuthConfig" }, { "type": "boolean", - "const": false + "enum": [false] } - ] + ], + "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection." }, "timeout": { - "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["type", "url"] + "required": ["type", "url"], + "additionalProperties": false }, "LayoutConfig": { - "description": "@deprecated Always uses stretch layout.", "type": "string", - "enum": ["auto", "stretch"] + "enum": ["auto", "stretch"], + "description": "@deprecated Always uses stretch layout." }, "Config": { "type": "object", "properties": { "$schema": { - "description": "JSON schema reference for configuration validation", "type": "string" }, "shell": { - "description": "Default shell to use for terminal and bash tool", "type": "string" }, "logLevel": { @@ -11704,11 +11500,7 @@ "$ref": "#/components/schemas/ServerConfig" }, "command": { - "description": "Command configuration, see https://opencode.ai/docs/commands", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -11728,28 +11520,27 @@ "type": "boolean" } }, - "required": ["template"] + "required": ["template"], + "additionalProperties": false } }, "skills": { - "description": "Additional skill folder paths", "type": "object", "properties": { "paths": { - "description": "Additional paths to skill folders", "type": "array", "items": { "type": "string" } }, "urls": { - "description": "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", "type": "array", "items": { "type": "string" } } - } + }, + "additionalProperties": false }, "watcher": { "type": "object", @@ -11760,10 +11551,10 @@ "type": "string" } } - } + }, + "additionalProperties": false }, "snapshot": { - "description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", "type": "boolean" }, "plugin": { @@ -11780,70 +11571,59 @@ "type": "string" }, { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } - ] + ], + "maxItems": 2, + "minItems": 2 } ] } }, "share": { - "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", "type": "string", "enum": ["manual", "auto", "disabled"] }, "autoshare": { - "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", "type": "boolean" }, "autoupdate": { - "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", "anyOf": [ { "type": "boolean" }, { "type": "string", - "const": "notify" + "enum": ["notify"] } - ] + ], + "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications" }, "disabled_providers": { - "description": "Disable providers that are loaded automatically", "type": "array", "items": { "type": "string" } }, "enabled_providers": { - "description": "When set, ONLY these providers will be enabled. All other providers will be ignored", "type": "array", "items": { "type": "string" } }, "model": { - "description": "Model to use in the format of provider/model, eg anthropic/claude-2", "type": "string" }, "small_model": { - "description": "Small model to use for tasks like title generation in the format of provider/model", "type": "string" }, "default_agent": { - "description": "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", "type": "string" }, "username": { - "description": "Custom username to display in conversations instead of system username", "type": "string" }, "mode": { - "description": "@deprecated Use `agent` field instead.", "type": "object", "properties": { "build": { @@ -11858,7 +11638,6 @@ } }, "agent": { - "description": "Agent configuration, see https://opencode.ai/docs/agents", "type": "object", "properties": { "plan": { @@ -11888,32 +11667,20 @@ } }, "provider": { - "description": "Custom provider configurations and model overrides", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/ProviderConfig" } }, "mcp": { - "description": "MCP (Model Context Protocol) server configurations", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "anyOf": [ { - "anyOf": [ - { - "$ref": "#/components/schemas/McpLocalConfig" - }, - { - "$ref": "#/components/schemas/McpRemoteConfig" - } - ] + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" }, { "type": "object", @@ -11922,7 +11689,8 @@ "type": "boolean" } }, - "required": ["enabled"] + "required": ["enabled"], + "additionalProperties": false } ] } @@ -11934,9 +11702,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -11951,9 +11716,6 @@ }, "environment": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -11964,10 +11726,12 @@ "type": "string" } } - } + }, + "additionalProperties": false } } - ] + ], + "description": "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides." }, "lsp": { "anyOf": [ @@ -11976,9 +11740,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "anyOf": [ { @@ -11986,10 +11747,11 @@ "properties": { "disabled": { "type": "boolean", - "const": true + "enum": [true] } }, - "required": ["disabled"] + "required": ["disabled"], + "additionalProperties": false }, { "type": "object", @@ -12011,30 +11773,24 @@ }, "env": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "initialization": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } ] } } - ] + ], + "description": "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides." }, "instructions": { - "description": "Additional instruction files or patterns to include", "type": "array", "items": { "type": "string" @@ -12048,9 +11804,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -12059,59 +11812,48 @@ "type": "object", "properties": { "url": { - "description": "Enterprise URL", "type": "string" } - } + }, + "additionalProperties": false }, "tool_output": { - "description": "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", "type": "object", "properties": { "max_lines": { - "description": "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "max_bytes": { - "description": "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } - } + }, + "additionalProperties": false }, "compaction": { "type": "object", "properties": { "auto": { - "description": "Enable automatic compaction when context is full (default: true)", "type": "boolean" }, "prune": { - "description": "Enable pruning of old tool outputs (default: true)", "type": "boolean" }, "tail_turns": { - "description": "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "preserve_recent_tokens": { - "description": "Maximum number of tokens from recent turns to preserve verbatim after compaction", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reserved": { - "description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } - } + }, + "additionalProperties": false }, "experimental": { "type": "object", @@ -12120,200 +11862,30 @@ "type": "boolean" }, "batch_tool": { - "description": "Enable the batch tool", "type": "boolean" }, "openTelemetry": { - "description": "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", "type": "boolean" }, "primary_tools": { - "description": "Tools that should only be available to primary agents.", "type": "array", "items": { "type": "string" } }, "continue_loop_on_deny": { - "description": "Continue the agent loop when a tool call is denied", "type": "boolean" }, "mcp_timeout": { - "description": "Timeout in milliseconds for model context protocol (MCP) requests", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } - } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "BadRequestError": { - "type": "object", - "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "success": { - "type": "boolean", - "const": false - } - }, - "required": ["data", "errors", "success"] - }, - "OAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "oauth" - }, - "refresh": { - "type": "string" - }, - "access": { - "type": "string" - }, - "expires": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "accountId": { - "type": "string" - }, - "enterpriseUrl": { - "type": "string" - } - }, - "required": ["type", "refresh", "access", "expires"] - }, - "ApiAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "api" - }, - "key": { - "type": "string" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["type", "key"] - }, - "WellKnownAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "wellknown" - }, - "key": { - "type": "string" - }, - "token": { - "type": "string" - } - }, - "required": ["type", "key", "token"] - }, - "Auth": { - "anyOf": [ - { - "$ref": "#/components/schemas/OAuth" - }, - { - "$ref": "#/components/schemas/ApiAuth" - }, - { - "$ref": "#/components/schemas/WellKnownAuth" - } - ] - }, - "Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - }, - "projectID": { - "type": "string" - } - }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] - }, - "NotFoundError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "const": "NotFoundError" - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["name", "data"] - }, "Model": { "type": "object", "properties": { @@ -12336,7 +11908,8 @@ "type": "string" } }, - "required": ["id", "url", "npm"] + "required": ["id", "url", "npm"], + "additionalProperties": false }, "name": { "type": "string" @@ -12378,7 +11951,8 @@ "type": "boolean" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": ["text", "audio", "image", "video", "pdf"], + "additionalProperties": false }, "output": { "type": "object", @@ -12399,7 +11973,8 @@ "type": "boolean" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": ["text", "audio", "image", "video", "pdf"], + "additionalProperties": false }, "interleaved": { "anyOf": [ @@ -12414,12 +11989,14 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"] + "required": ["field"], + "additionalProperties": false } ] } }, - "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"], + "additionalProperties": false }, "cost": { "type": "object", @@ -12440,7 +12017,8 @@ "type": "number" } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false }, "experimentalOver200K": { "type": "object", @@ -12461,13 +12039,16 @@ "type": "number" } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "cache"] + "required": ["input", "output", "cache"], + "additionalProperties": false } }, - "required": ["input", "output", "cache"] + "required": ["input", "output", "cache"], + "additionalProperties": false }, "limit": { "type": "object", @@ -12482,24 +12063,18 @@ "type": "number" } }, - "required": ["context", "output"] + "required": ["context", "output"], + "additionalProperties": false }, "status": { "type": "string", "enum": ["alpha", "beta", "deprecated", "active"] }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "headers": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -12509,15 +12084,8 @@ }, "variants": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } } }, @@ -12533,7 +12101,8 @@ "options", "headers", "release_date" - ] + ], + "additionalProperties": false }, "Provider": { "type": "object", @@ -12558,23 +12127,17 @@ "type": "string" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "models": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/Model" } } }, - "required": ["id", "name", "source", "env", "options", "models"] + "required": ["id", "name", "source", "env", "options", "models"], + "additionalProperties": false }, "ConsoleState": { "type": "object", @@ -12590,17 +12153,11 @@ }, "switchableOrgCount": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["consoleManagedProviders", "switchableOrgCount"] - }, - "ToolIDs": { - "type": "array", - "items": { - "type": "string" - } + "required": ["consoleManagedProviders", "switchableOrgCount"], + "additionalProperties": false }, "ToolListItem": { "type": "object", @@ -12613,7 +12170,8 @@ }, "parameters": {} }, - "required": ["id", "description", "parameters"] + "required": ["id", "description", "parameters"], + "additionalProperties": false }, "ToolList": { "type": "array", @@ -12621,6 +12179,25 @@ "$ref": "#/components/schemas/ToolListItem" } }, + "ToolIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "WorktreeCreateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "startCommand": { + "type": "string", + "description": "Additional startup script to run after the project's start command" + } + }, + "additionalProperties": false + }, "Worktree": { "type": "object", "properties": { @@ -12634,19 +12211,8 @@ "type": "string" } }, - "required": ["name", "branch", "directory"] - }, - "WorktreeCreateInput": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "startCommand": { - "description": "Additional startup script to run after the project's start command", - "type": "string" - } - } + "required": ["name", "branch", "directory"], + "additionalProperties": false }, "WorktreeRemoveInput": { "type": "object", @@ -12655,7 +12221,8 @@ "type": "string" } }, - "required": ["directory"] + "required": ["directory"], + "additionalProperties": false }, "WorktreeResetInput": { "type": "object", @@ -12664,7 +12231,8 @@ "type": "string" } }, - "required": ["directory"] + "required": ["directory"], + "additionalProperties": false }, "ProjectSummary": { "type": "object", @@ -12679,14 +12247,14 @@ "type": "string" } }, - "required": ["id", "worktree"] + "required": ["id", "worktree"], + "additionalProperties": false }, "GlobalSession": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "slug": { "type": "string" @@ -12695,8 +12263,7 @@ "type": "string" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" }, "directory": { "type": "string" @@ -12705,26 +12272,22 @@ "type": "string" }, "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "summary": { "type": "object", "properties": { "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "files": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "diffs": { "type": "array", @@ -12733,7 +12296,8 @@ } } }, - "required": ["additions", "deletions", "files"] + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, "share": { "type": "object", @@ -12742,11 +12306,31 @@ "type": "string" } }, - "required": ["url"] + "required": ["url"], + "additionalProperties": false }, "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "version": { "type": "string" }, @@ -12755,24 +12339,22 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacting": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "archived": { "type": "number" } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -12781,12 +12363,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "partID": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "snapshot": { "type": "string" @@ -12795,7 +12375,8 @@ "type": "string" } }, - "required": ["messageID"] + "required": ["messageID"], + "additionalProperties": false }, "project": { "anyOf": [ @@ -12808,7 +12389,8 @@ ] } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"], + "additionalProperties": false }, "McpResource": { "type": "object", @@ -12829,274 +12411,8 @@ "type": "string" } }, - "required": ["name", "uri", "client"] - }, - "TextPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "text" - }, - "text": { - "type": "string" - }, - "synthetic": { - "type": "boolean" - }, - "ignored": { - "type": "boolean" - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["start"] - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["type", "text"] - }, - "FilePartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"] - }, - "AgentPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "agent" - }, - "name": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["value", "start", "end"] - } - }, - "required": ["type", "name"] - }, - "SubtaskPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "subtask" - }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "command": { - "type": "string" - } - }, - "required": ["type", "prompt", "description", "agent"] - }, - "ProviderAuthMethod": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["oauth", "api"] - }, - "label": { - "type": "string" - }, - "prompts": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "placeholder": { - "type": "string" - }, - "when": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "op": { - "type": "string", - "enum": ["eq", "neq"] - }, - "value": { - "type": "string" - } - }, - "required": ["key", "op", "value"] - } - }, - "required": ["type", "key", "message"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "select" - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "options": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "hint": { - "type": "string" - } - }, - "required": ["label", "value"] - } - }, - "when": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "op": { - "type": "string", - "enum": ["eq", "neq"] - }, - "value": { - "type": "string" - } - }, - "required": ["key", "op", "value"] - } - }, - "required": ["type", "key", "message", "options"] - } - ] - } - } - }, - "required": ["type", "label"] - }, - "ProviderAuthAuthorization": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "method": { - "type": "string", - "enum": ["auto", "code"] - }, - "instructions": { - "type": "string" - } - }, - "required": ["url", "method", "instructions"] + "required": ["name", "uri", "client"], + "additionalProperties": false }, "Symbol": { "type": "object", @@ -13106,8 +12422,7 @@ }, "kind": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "location": { "type": "object", @@ -13119,10 +12434,12 @@ "$ref": "#/components/schemas/Range" } }, - "required": ["uri", "range"] + "required": ["uri", "range"], + "additionalProperties": false } }, - "required": ["name", "kind", "location"] + "required": ["name", "kind", "location"], + "additionalProperties": false }, "FileNode": { "type": "object", @@ -13144,7 +12461,8 @@ "type": "boolean" } }, - "required": ["name", "path", "absolute", "type", "ignored"] + "required": ["name", "path", "absolute", "type", "ignored"], + "additionalProperties": false }, "FileContent": { "type": "object", @@ -13181,23 +12499,19 @@ "properties": { "oldStart": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "oldLines": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "newStart": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "newLines": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "lines": { "type": "array", @@ -13206,24 +12520,27 @@ } } }, - "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"], + "additionalProperties": false } }, "index": { "type": "string" } }, - "required": ["oldFileName", "newFileName", "hunks"] + "required": ["oldFileName", "newFileName", "hunks"], + "additionalProperties": false }, "encoding": { "type": "string", - "const": "base64" + "enum": ["base64"] }, "mimeType": { "type": "string" } }, - "required": ["type", "content"] + "required": ["type", "content"], + "additionalProperties": false }, "File": { "type": "object", @@ -13233,249 +12550,19 @@ }, "added": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "removed": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["path", "added", "removed", "status"] - }, - "Event": { - "anyOf": [ - { - "$ref": "#/components/schemas/Event.project.updated" - }, - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, - { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" - }, - { - "$ref": "#/components/schemas/Event.lsp.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.delta" - }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, - { - "$ref": "#/components/schemas/Event.session.diff" - }, - { - "$ref": "#/components/schemas/Event.session.error" - }, - { - "$ref": "#/components/schemas/Event.installation.updated" - }, - { - "$ref": "#/components/schemas/Event.installation.update-available" - }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" - }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/Event.mcp.tools.changed" - }, - { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" - }, - { - "$ref": "#/components/schemas/Event.command.executed" - }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" - }, - { - "$ref": "#/components/schemas/Event.worktree.ready" - }, - { - "$ref": "#/components/schemas/Event.worktree.failed" - }, - { - "$ref": "#/components/schemas/Event.pty.created" - }, - { - "$ref": "#/components/schemas/Event.pty.updated" - }, - { - "$ref": "#/components/schemas/Event.pty.exited" - }, - { - "$ref": "#/components/schemas/Event.pty.deleted" - }, - { - "$ref": "#/components/schemas/Event.message.updated" - }, - { - "$ref": "#/components/schemas/Event.message.removed" - }, - { - "$ref": "#/components/schemas/Event.message.part.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.removed" - }, - { - "$ref": "#/components/schemas/Event.session.created" - }, - { - "$ref": "#/components/schemas/Event.session.updated" - }, - { - "$ref": "#/components/schemas/Event.session.deleted" - } - ] - }, - "MCPStatusConnected": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "connected" - } - }, - "required": ["status"] - }, - "MCPStatusDisabled": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "disabled" - } - }, - "required": ["status"] - }, - "MCPStatusFailed": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "failed" - }, - "error": { - "type": "string" - } - }, - "required": ["status", "error"] - }, - "MCPStatusNeedsAuth": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "needs_auth" - } - }, - "required": ["status"] - }, - "MCPStatusNeedsClientRegistration": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "needs_client_registration" - }, - "error": { - "type": "string" - } - }, - "required": ["status", "error"] - }, - "MCPStatus": { - "anyOf": [ - { - "$ref": "#/components/schemas/MCPStatusConnected" - }, - { - "$ref": "#/components/schemas/MCPStatusDisabled" - }, - { - "$ref": "#/components/schemas/MCPStatusFailed" - }, - { - "$ref": "#/components/schemas/MCPStatusNeedsAuth" - }, - { - "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" - } - ] - }, - "McpUnsupportedOAuthError": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] + "required": ["path", "added", "removed", "status"], + "additionalProperties": false }, "Path": { "type": "object", @@ -13496,7 +12583,8 @@ "type": "string" } }, - "required": ["home", "state", "config", "worktree", "directory"] + "required": ["home", "state", "config", "worktree", "directory"], + "additionalProperties": false }, "VcsInfo": { "type": "object", @@ -13507,7 +12595,8 @@ "default_branch": { "type": "string" } - } + }, + "additionalProperties": false }, "VcsFileDiff": { "type": "object", @@ -13520,20 +12609,19 @@ }, "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"] + "required": ["file", "patch", "additions", "deletions"], + "additionalProperties": false }, "Command": { "type": "object", @@ -13555,14 +12643,7 @@ "enum": ["command", "mcp", "skill"] }, "template": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "string" - } - ] + "type": "string" }, "subtask": { "type": "boolean" @@ -13574,7 +12655,8 @@ } } }, - "required": ["name", "template", "hints"] + "required": ["name", "template", "hints"], + "additionalProperties": false }, "Agent": { "type": "object", @@ -13617,7 +12699,8 @@ "type": "string" } }, - "required": ["modelID", "providerID"] + "required": ["modelID", "providerID"], + "additionalProperties": false }, "variant": { "type": "string" @@ -13626,17 +12709,14 @@ "type": "string" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "steps": { "type": "number" } }, - "required": ["name", "mode", "permission", "options"] + "required": ["name", "mode", "permission", "options"], + "additionalProperties": false }, "LSPStatus": { "type": "object", @@ -13651,19 +12731,12 @@ "type": "string" }, "status": { - "anyOf": [ - { - "type": "string", - "const": "connected" - }, - { - "type": "string", - "const": "error" - } - ] + "type": "string", + "enum": ["connected", "error"] } }, - "required": ["id", "name", "root", "status"] + "required": ["id", "name", "root", "status"], + "additionalProperties": false }, "FormatterStatus": { "type": "object", @@ -13681,8 +12754,5391 @@ "type": "boolean" } }, - "required": ["name", "extensions", "enabled"] + "required": ["name", "extensions", "enabled"], + "additionalProperties": false + }, + "MCPStatusConnected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["connected"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusDisabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["disabled"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusFailed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "MCPStatusNeedsAuth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_auth"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusNeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_client_registration"] + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "MCPStatus": { + "anyOf": [ + { + "$ref": "#/components/schemas/MCPStatusConnected" + }, + { + "$ref": "#/components/schemas/MCPStatusDisabled" + }, + { + "$ref": "#/components/schemas/MCPStatusFailed" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsAuth" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" + } + ] + }, + "McpUnsupportedOAuthError": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "additionalProperties": false + }, + "effect_HttpApiError_Forbidden": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Forbidden"] + } + }, + "required": ["_tag"], + "additionalProperties": false + }, + "ProviderAuthMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth", "api"] + }, + "label": { + "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + } + }, + "required": ["type", "key", "message"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["select"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + } + }, + "required": ["type", "key", "message", "options"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["type", "label"], + "additionalProperties": false + }, + "ProviderAuthAuthorization": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "method": { + "type": "string", + "enum": ["auto", "code"] + }, + "instructions": { + "type": "string" + } + }, + "required": ["url", "method", "instructions"], + "additionalProperties": false + }, + "TextPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["start"], + "additionalProperties": false + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "FilePartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + }, + "AgentPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["agent"] + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["value", "start", "end"], + "additionalProperties": false + } + }, + "required": ["type", "name"], + "additionalProperties": false + }, + "SubtaskPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["subtask"] + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "command": { + "type": "string" + } + }, + "required": ["type", "prompt", "description", "agent"], + "additionalProperties": false + }, + "V2SessionsResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "V2SessionMessagesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "EventTuiPromptAppend": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiCommandExecute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiToastShow": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiSessionSelect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "projectID": { + "type": "string" + } + }, + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], + "additionalProperties": false + }, + "SyncEventMessageUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessageRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionCreated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.created.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "object", + "properties": { + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "share": { + "type": "object", + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "updated": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "compacting": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "archived": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "permission": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionRuleset" + }, + { + "type": "null" + } + ] + }, + "revert": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionDeleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.deleted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.agent.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextModelSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.model.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextPrompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.prompted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextSynthetic": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.synthetic.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0 + }, + "output": { + "type": "integer", + "minimum": 0 + }, + "reasoning": { + "type": "integer", + "minimum": 0 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0 + }, + "write": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepFailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.failed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolCalled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.called.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolProgress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.progress.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolSuccess": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.success.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolFailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.failed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextRetried": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.retried.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspClientDiagnostics": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.client.diagnostics"] + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "requestID": { + "type": "string" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDiff": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionError": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdate-available": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionReplied" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionRejected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionRejected" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionIdle": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpBrowserOpenFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventVcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceRestore": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.restore"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "step": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name", "branch"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "PromptSource": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": ["start", "end", "text"], + "additionalProperties": false + }, + "PromptFileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["uri", "mime"], + "additionalProperties": false + }, + "PromptAgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["name"], + "additionalProperties": false + }, + "EventSessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0 + }, + "output": { + "type": "integer", + "minimum": 0 + }, + "reasoning": { + "type": "integer", + "minimum": 0 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0 + }, + "write": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "ToolTextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "ToolFileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["file"] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"], + "additionalProperties": false + }, + "EventSessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "SessionNextRetry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "minimum": 0 + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"], + "additionalProperties": false + }, + "EventSessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "SessionInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "parentID": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string" + }, + "path": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "title": { + "type": "string" + } + }, + "required": ["id", "projectID", "time", "title"], + "additionalProperties": false + }, + "SessionDelivery": { + "type": "string", + "enum": ["immediate", "deferred"] + }, + "SessionMessageAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["agent-switched"] + }, + "agent": { + "type": "string" + } + }, + "required": ["id", "time", "type", "agent"], + "additionalProperties": false + }, + "SessionMessageModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["model-switched"] + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["id", "time", "type", "model"], + "additionalProperties": false + }, + "SessionMessageUser": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } + }, + "type": { + "type": "string", + "enum": ["user"] + } + }, + "required": ["id", "time", "text", "type"], + "additionalProperties": false + }, + "SessionMessageSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["synthetic"] + } + }, + "required": ["id", "time", "sessionID", "text", "type"], + "additionalProperties": false + }, + "SessionMessageShell": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["shell"] + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["id", "time", "type", "callID", "command", "output"], + "additionalProperties": false + }, + "SessionMessageAssistantText": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "SessionMessageAssistantReasoning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning"] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["type", "id", "text"], + "additionalProperties": false + }, + "SessionMessageToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "input": { + "type": "string" + } + }, + "required": ["status", "input"], + "additionalProperties": false + }, + "SessionMessageToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["running"] + }, + "input": { + "type": "object" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["status", "input", "structured", "content"], + "additionalProperties": false + }, + "SessionMessageToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["completed"] + }, + "input": { + "type": "object" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "structured": { + "type": "object" + } + }, + "required": ["status", "input", "content", "structured"], + "additionalProperties": false + }, + "SessionMessageToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["error"] + }, + "input": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "structured": { + "type": "object" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["status", "input", "content", "structured", "error"], + "additionalProperties": false + }, + "SessionMessageAssistantTool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tool"] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageToolStatePending" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateRunning" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateCompleted" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateError" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "pruned": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "id", "name", "state", "time"], + "additionalProperties": false + }, + "SessionMessageAssistant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["assistant"] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAssistantText" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantReasoning" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantTool" + } + ] + } + }, + "snapshot": { + "type": "object", + "properties": { + "start": { + "type": "string" + }, + "end": { + "type": "string" + } + }, + "additionalProperties": false + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["id", "time", "type", "agent", "model", "content"], + "additionalProperties": false + }, + "SessionMessageCompaction": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["compaction"] + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "summary": { + "type": "string" + }, + "include": { + "type": "string" + }, + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "reason", "summary", "id", "time"], + "additionalProperties": false + }, + "SessionMessage": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionMessageModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionMessageUser" + }, + { + "$ref": "#/components/schemas/SessionMessageSynthetic" + }, + { + "$ref": "#/components/schemas/SessionMessageShell" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistant" + }, + { + "$ref": "#/components/schemas/SessionMessageCompaction" + } + ] + }, + "EventTuiToastShow1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "BadRequestError": { + "type": "object", + "required": ["data", "errors", "success"], + "properties": { + "data": {}, + "errors": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "success": { + "type": "boolean", + "enum": [false] + } + } + }, + "NotFoundError": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string", + "enum": ["NotFoundError"] + }, + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + } + } } } - } + }, + "security": [], + "tags": [ + { + "name": "control", + "description": "Control plane routes." + }, + { + "name": "global", + "description": "Global server routes." + }, + { + "name": "event", + "description": "Instance event stream route." + }, + { + "name": "config", + "description": "Experimental HttpApi config routes." + }, + { + "name": "experimental", + "description": "Experimental HttpApi read-only routes." + }, + { + "name": "file", + "description": "Experimental HttpApi file routes." + }, + { + "name": "instance", + "description": "Experimental HttpApi instance read routes." + }, + { + "name": "mcp", + "description": "Experimental HttpApi MCP routes." + }, + { + "name": "project", + "description": "Experimental HttpApi project routes." + }, + { + "name": "pty", + "description": "Experimental HttpApi PTY routes." + }, + { + "name": "question", + "description": "Question routes." + }, + { + "name": "permission", + "description": "Experimental HttpApi permission routes." + }, + { + "name": "provider", + "description": "Experimental HttpApi provider routes." + }, + { + "name": "session", + "description": "Experimental HttpApi session routes." + }, + { + "name": "sync", + "description": "Experimental HttpApi sync routes." + }, + { + "name": "v2", + "description": "Experimental v2 routes." + }, + { + "name": "v2 messages", + "description": "Experimental v2 message routes." + }, + { + "name": "tui", + "description": "Experimental HttpApi TUI routes." + }, + { + "name": "workspace", + "description": "Experimental HttpApi workspace routes." + }, + { + "name": "pty", + "description": "PTY websocket route." + } + ] } diff --git a/packages/storybook/package.json b/packages/storybook/package.json index a76f4043e1..a8676ece9c 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.42", + "version": "7.2.52", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 2520e930f9..eda75d1c2a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.42", + "version": "7.2.52", "type": "module", "license": "MIT", "exports": { @@ -44,7 +44,6 @@ "dependencies": { "@kobalte/core": "catalog:", "@kilocode/sdk": "workspace:*", - "@opencode-ai/core": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", @@ -54,13 +53,14 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "diff": "catalog:", - "dompurify": "3.3.3", + "dompurify": "3.4.2", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", + "mermaid": "11.14.0", "morphdom": "2.7.8", "motion": "12.34.5", "motion-dom": "12.34.3", @@ -72,8 +72,8 @@ "solid-list": "catalog:", "strip-ansi": "7.1.2", "virtua": "catalog:", - "mermaid": "11.14.0", - "@typescript/native-preview": "catalog:" + "@typescript/native-preview": "catalog:", + "@opencode-ai/core": "workspace:*" }, "peerDependencies": {} } diff --git a/script/raw-changelog.ts b/script/raw-changelog.ts index 6c9cd7b4f0..4dbeceb308 100644 --- a/script/raw-changelog.ts +++ b/script/raw-changelog.ts @@ -82,6 +82,11 @@ function section(areas: Set) { return "Core" } +function type(message: string) { + if (message.match(/fix/i)) return "Bugfixes" + return "Improvements" +} + function reverted(commits: Commit[]) { const seen = new Map() @@ -193,13 +198,20 @@ async function thanks(from: string, to: string, reuse: boolean) { } function format(from: string, to: string, list: Commit[], thanks: string[]) { - const grouped = new Map() - for (const title of order) grouped.set(title, []) + const grouped = new Map>() + for (const title of order) { + grouped.set( + title, + new Map([ + ["Improvements", []], + ["Bugfixes", []], + ]), + ) + } for (const commit of list) { - const title = section(commit.areas) const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" - grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) + grouped.get(section(commit.areas))!.get(type(commit.message))!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) } const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""] @@ -209,11 +221,23 @@ function format(from: string, to: string, list: Commit[], thanks: string[]) { } for (const title of order) { - const entries = grouped.get(title) - if (!entries || entries.length === 0) continue + const groups = grouped.get(title) + if (!groups || [...groups.values()].every((entries) => entries.length === 0)) continue lines.push(`## ${title}`) - lines.push(...entries) - lines.push("") + const improvements = groups.get("Improvements")! + const bugfixes = groups.get("Bugfixes")! + if (bugfixes.length === 0) { + lines.push(...improvements) + lines.push("") + continue + } + + for (const [subtitle, entries] of groups) { + if (entries.length === 0) continue + lines.push(`### ${subtitle}`) + lines.push(...entries) + lines.push("") + } } if (thanks.length > 0) { diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 6c58312aa2..86951b0f0b 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.42", + "version": "7.2.52", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md new file mode 100644 index 0000000000..20d84c8f47 --- /dev/null +++ b/specs/v2/session-concepts-gap.md @@ -0,0 +1,131 @@ +# Session V2 Concept Gaps + +Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. + +## Message Metadata + +- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. +- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. + +## Output Format + +- Text output format. +- JSON-schema output format. +- Structured-output retry count. +- Structured assistant result payload. +- Structured-output error classification. + +## Errors + +- Aborted error. +- Provider auth error. +- API error with status, retryability, headers, body, and metadata. +- Context-overflow error. +- Output-length error. +- Unknown error. +- V2 mostly reduces assistant errors to strings, except retry errors. + +## Part Identity + +- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. +- V2 assistant content does not preserve stable per-content IDs. +- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. + +## Part Timing And Metadata + +- V1 text, reasoning, and tool states carry timing and provider metadata. +- V2 assistant text and reasoning content only store text. +- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. + +## Snapshots And Patches + +- Snapshot parts. +- Patch parts. +- Step-start snapshot references. +- Step-finish snapshot references. +- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. + +## Step Boundaries + +- V1 stores `step-start` and `step-finish` as first-class parts. +- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. +- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. + +## Compaction + +- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. +- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. +- V1 also has history filtering semantics around completed summary messages and retained tails. + +## Files And Sources + +- V1 file parts have `mime`, `filename`, `url`, and typed source information. +- V1 source variants include file, symbol, and resource sources. +- Symbol sources include LSP range, name, and kind. +- Resource sources include client name and URI. +- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. + +## Agents And Subtasks + +- Agent parts. +- Subtask parts. +- Subtask prompt, description, agent, model, and command. +- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. + +## Text Flags + +- Synthetic text flag. +- Ignored text flag. +- V2 has a separate synthetic entry, but no ignored text concept. + +## Tool Calls + +- V1 pending tool state stores parsed input and raw input text separately. +- V2 pending tool state stores a string input but does not preserve a separate raw field. +- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. +- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. +- V1 error tool state has `time.start` and `time.end`. +- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. +- V1 tracks provider execution and provider call metadata. +- V2 events include provider info, but `SessionEntryStepper` drops it from entries. +- V1 has tool-output compaction and truncation behavior via `time.compacted`. + +## Media Handling + +- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. +- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. +- V2 has attachments but not these model-message conversion semantics. + +## Retries + +- V1 stores retries as independently addressable retry parts. +- V2 stores retries as an assistant aggregate. +- V2 captures some retry information, but not the independent part identity/update model. + +## Processor Control Flow + +- Session status transitions: busy, retry, and idle. +- Retry policy integration. +- Context-overflow-driven compaction. +- Abort and interrupt handling. +- Permission-denied blocking. +- Doom-loop detection. +- Plugin hook for `experimental.text.complete`. +- Background summary generation after steps. +- Cleanup semantics for open text, reasoning, and tool calls. + +## Sync And Bus Events + +- Message updated. +- Message removed. +- Message part updated. +- Message part delta. +- Message part removed. +- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. + +## History Retrieval + +- Cursor encoding and decoding. +- Paged message retrieval. +- Reverse streaming through history. +- Compaction-aware history filtering.