Compare commits

...
Author SHA1 Message Date
Kit Langton 3d7501a6a3 refactor(task): split task spec from prompt execution 2026-04-10 13:42:58 -04:00
Kit Langton bf601628db refactor(tool): convert codesearch tool internals to Effect (#21811) 2026-04-10 11:49:20 -04:00
opencode-agent[bot] 00e39d2114 chore: generate 2026-04-10 15:31:39 +00:00
Kit Langton 46b74e0873 refactor(tool): convert websearch tool internals to Effect (#21810) 2026-04-10 11:30:38 -04:00
opencode-agent[bot] aedc4e964f chore: generate 2026-04-10 14:51:27 +00:00
Kit Langton e83404367c refactor(tool): convert webfetch tool internals to Effect (#21809) 2026-04-10 10:50:13 -04:00
James Long 42206da1f8 refactor(tui): switch to global events and start passing workspace param (#21719) 2026-04-10 10:47:27 -04:00
Kit Langton 44f38193c0 refactor(tool): convert plan tool internals to Effect (#21807) 2026-04-10 10:38:46 -04:00
opencode-agent[bot] 9a6b455bfe chore: generate 2026-04-10 14:08:27 +00:00
Kit Langton 8063e0b5c6 refactor(tool): convert lsp tool internals to Effect (#21806) 2026-04-10 10:07:19 -04:00
Kit Langton 157c5d77f8 refactor(tool): convert question tool internals to Effect (#21808) 2026-04-10 09:42:06 -04:00
Aiden Cline ce19c051be fix: ts lsp (#21827) 2026-04-10 00:15:45 -05:00
Kit Langton 91786d2fc1 refactor(effect): use Git service in file and storage (#21803) 2026-04-09 22:49:36 -04:00
Kit Langton eca11ca71a refactor(effect): use SessionRevert service in prompt (#21796) 2026-04-09 22:28:11 -04:00
Kit Langton 17bd16667c refactor(effect): move tool descriptions into registry (#21795) 2026-04-09 22:20:27 -04:00
Kit Langton 16c60c9ee7 refactor(session): extract sharing orchestration (#21759) 2026-04-09 21:47:48 -04:00
Dax Raad 0970b102e1 Merge remote-tracking branch 'origin/dev' into dev 2026-04-09 21:35:09 -04:00
Dax Raad 04074d3f4a core: enable prod channel to use shared production database
Ensures users on the prod channel have their data persisted to the same
database as latest and beta channels, preventing data fragmentation
across different release channels.
2026-04-09 21:34:52 -04:00
Luke Parker b16ee08fd5 ci use node 24 in test workflow fixing random ECONNRESET (#21782) 2026-04-10 01:00:21 +00:00
Luke Parker 98874a09f7 fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) 2026-04-10 01:00:21 +00:00
opencode 877be7e8e0 release: v1.4.3 2026-04-10 01:00:12 +00:00
Dax Raad eac50f9151 ci: prevent beta branch builds from triggering production release steps
Skip Windows and Linux code signing, along with artifact downloads for
the beta branch to ensure beta builds don't go through production
release processes.
2026-04-09 17:06:53 -04:00
Dax Raad 1a902b291c ci: skip winget publish on beta and ensure finalize always runs
Beta releases no longer trigger unnecessary Winget submissions, and release
finalization now completes even when some build artifacts are missing.
2026-04-09 16:34:05 -04:00
opencode-agent[bot] bbe4a04f9f chore: generate 2026-04-09 20:29:48 +00:00
Kit Langton b2f621b897 refactor(session): inline init route orchestration (#21754) 2026-04-09 16:28:42 -04:00
Aiden ClineandOpeOginni 7202b3a325 fix: ensure that openai oauth works for agent create cmd, use temporary hack (#21749)
Co-authored-by: OpeOginni <brightoginni123@gmail.com>
2026-04-09 15:25:59 -05:00
opencode-agent[bot] 35b44df94a chore: generate 2026-04-09 20:05:05 +00:00
Kit Langton 10441efad1 refactor(effect): extract session run state service (#21744) 2026-04-09 16:03:40 -04:00
Kit Langton 3199383eef fix: finalize interrupted bash via tool result path (#21724) 2026-04-09 15:20:28 -04:00
Kit Langton 9f54115c5d refactor: remove unused runtime facade exports (#21731) 2026-04-09 14:52:06 -04:00
Kit Langton 2ecc6ae65f fix(effect): suspend agent default layer construction (#21732) 2026-04-09 14:32:41 -04:00
Simon Klee 02b32e1ba7 Revert "opencode: lazy-load top-level CLI commands" (#21726) 2026-04-09 20:03:48 +02:00
Kit Langton 34b9792654 delete unused withALS method (#21723) 2026-04-09 13:52:58 -04:00
Simon Klee 537160dbc0 opencode: lazy-load top-level CLI commands
The CLI imports every top-level command before argument parsing has
decided which handler will run. This makes simple invocations pay for
the full command graph up front and slows down the default startup path.

Parse the root argv first and load only the command module that matches
the selected top-level command. Keep falling back to the default TUI
path for non-command positionals, and preserve root help, version and
completion handling
2026-04-09 19:42:25 +02:00
Aiden Cline b0600664ab feat: add support for fast modes for claude and gpt models (that support it) (#21706) 2026-04-09 12:06:26 -05:00
Kit Langton 581a7692ff fix(tui): restore hidden session scrollbar default (#20947) 2026-04-09 13:01:08 -04:00
opencode-agent[bot] f73e4d5d31 chore: generate 2026-04-09 16:47:02 +00:00
Aleksandr LossenkoandAiden Cline a7743e6467 feat(mcp): add OAuth redirect URI configuration for MCP servers (#21385)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-09 11:45:52 -05:00
opencode 5d3dba666c release: v1.4.2 2026-04-09 16:24:43 +00:00
Dax bd53b651a3 refactor: fix tool call state handling and clean up imports (#21709) 2026-04-09 11:56:19 -04:00
Kit Langton 46da801f30 refactor(effect): drop shell abort signals from runner (#21599) 2026-04-09 10:54:26 -04:00
Kit Langton 58a99916bb fix: preserve text part timing in session processor (#21691) 2026-04-09 10:05:14 -04:00
Kit Langton c29392d085 fix: preserve interrupted bash output in tool results (#21598) 2026-04-09 10:03:26 -04:00
Brendan Allan 46f243fea7 app: remove min loading duration (#21655) 2026-04-09 16:29:46 +08:00
121 changed files with 3889 additions and 2719 deletions
+6 -3
View File
@@ -114,7 +114,7 @@ jobs:
- build-cli - build-cli
- version - version
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -213,6 +213,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -389,6 +390,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -421,7 +423,6 @@ jobs:
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
platform_flag: --linux platform_flag: --linux
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -547,6 +548,7 @@ jobs:
- sign-cli-windows - sign-cli-windows
- build-tauri - build-tauri
- build-electron - build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -589,12 +591,13 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: needs.version.outputs.release if: needs.version.outputs.release && github.ref_name != 'beta'
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/latest-yml path: /tmp/latest-yml
+13
View File
@@ -17,6 +17,9 @@ permissions:
contents: read contents: read
checks: write checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
unit: unit:
name: unit (${{ matrix.settings.name }}) name: unit (${{ matrix.settings.name }})
@@ -38,6 +41,11 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -102,6 +110,11 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
+16 -16
View File
@@ -27,7 +27,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -81,7 +81,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -115,7 +115,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -142,7 +142,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -166,7 +166,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -190,7 +190,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -223,7 +223,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
@@ -266,7 +266,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -295,7 +295,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -311,7 +311,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.4.1", "version": "1.4.3",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -447,7 +447,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -481,7 +481,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -496,7 +496,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -531,7 +531,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -580,7 +580,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -591,7 +591,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+7 -3
View File
@@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") {
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
} }
function done(proc: ReturnType<typeof spawn>) {
return proc.exitCode !== null || proc.signalCode !== null
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) { async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (proc.exitCode !== null) return if (done(proc)) return
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())), new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)), new Promise<void>((resolve) => setTimeout(resolve, timeout)),
@@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
return { return {
url, url,
async stop() { async stop() {
if (proc.exitCode === null) { if (!done(proc)) {
proc.kill("SIGTERM") proc.kill("SIGTERM")
await waitExit(proc) await waitExit(proc)
} }
if (proc.exitCode === null) { if (!done(proc)) {
proc.kill("SIGKILL") proc.kill("SIGKILL")
await waitExit(proc) await waitExit(proc)
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.1", "version": "1.4.3",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
-1
View File
@@ -182,7 +182,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
if (checkMode() === "background" || type === "http") return false if (checkMode() === "background" || type === "http") return false
} }
}).pipe( }).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))), Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise, Effect.runPromise,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.1", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.1", "version": "1.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.1", "version": "1.4.3",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.1", "version": "1.4.3",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.4.1", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.4.1", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE
if (!releaseId) throw new Error("OPENCODE_RELEASE is required") if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
const version = process.env.OPENCODE_VERSION const version = process.env.OPENCODE_VERSION
if (!releaseId) throw new Error("OPENCODE_VERSION is required") if (!version) throw new Error("OPENCODE_VERSION is required")
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
@@ -54,7 +54,10 @@ const assets = release.assets ?? []
const assetByName = new Map(assets.map((asset) => [asset.name, asset])) const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
const latestAsset = assetByName.get("latest.json") const latestAsset = assetByName.get("latest.json")
if (!latestAsset) throw new Error("latest.json asset not found") if (!latestAsset) {
console.log("latest.json not found, skipping tauri finalization")
process.exit(0)
}
const latestRes = await fetch(latestAsset.url, { const latestRes = await fetch(latestAsset.url, {
headers: { headers: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.1", "version": "1.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.4.1" version = "1.4.3"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.1", "version": "1.4.3",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.4.1", "version": "1.4.3",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+2
View File
@@ -11,6 +11,8 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir) process.chdir(dir)
await import("./generate.ts")
// Load migrations from migration directories // Load migrations from migration directories
const migrationDirs = ( const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), { await fs.promises.readdir(path.join(dir, "migration"), {
+2 -15
View File
@@ -12,24 +12,11 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir) process.chdir(dir)
await import("./generate.ts")
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import pkg from "../package.json" import pkg from "../package.json"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
// Load migrations from migration directories // Load migrations from migration directories
const migrationDirs = ( const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), { await fs.promises.readdir(path.join(dir, "migration"), {
+23
View File
@@ -0,0 +1,23 @@
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
-17
View File
@@ -461,28 +461,11 @@ export namespace Account {
return Option.getOrUndefined(await runPromise((service) => service.active())) return Option.getOrUndefined(await runPromise((service) => service.active()))
} }
export async function list(): Promise<Info[]> {
return runPromise((service) => service.list())
}
export async function activeOrg(): Promise<ActiveOrg | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
}
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> { export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
return runPromise((service) => service.orgsByAccount()) return runPromise((service) => service.orgsByAccount())
} }
export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
return runPromise((service) => service.orgs(accountID))
}
export async function switchOrg(accountID: AccountID, orgID: OrgID) { export async function switchOrg(accountID: AccountID, orgID: OrgID) {
return runPromise((service) => service.use(accountID, Option.some(orgID))) return runPromise((service) => service.use(accountID, Option.some(orgID)))
} }
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t)
}
} }
+21 -14
View File
@@ -341,6 +341,10 @@ export namespace Agent {
) )
const existing = yield* InstanceState.useEffect(state, (s) => s.list()) const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = { const params = {
experimental_telemetry: { experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry, isEnabled: cfg.experimental?.openTelemetry,
@@ -350,12 +354,14 @@ export namespace Agent {
}, },
temperature: 0.3, temperature: 0.3,
messages: [ messages: [
...system.map( ...(isOpenaiOauth
(item): ModelMessage => ({ ? []
role: "system", : system.map(
content: item, (item): ModelMessage => ({
}), role: "system",
), content: item,
}),
)),
{ {
role: "user", role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
@@ -369,13 +375,12 @@ export namespace Agent {
}), }),
} satisfies Parameters<typeof generateObject>[0] } satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over if (isOpenaiOauth) {
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
if (model.providerID === "openai" && authInfo?.type === "oauth") {
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const result = streamObject({ const result = streamObject({
...params, ...params,
providerOptions: ProviderTransform.providerOptions(resolved, { providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false, store: false,
}), }),
onError: () => {}, onError: () => {},
@@ -393,11 +398,13 @@ export namespace Agent {
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = Layer.suspend(() =>
Layer.provide(Provider.defaultLayer), layer.pipe(
Layer.provide(Auth.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Skill.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+2
View File
@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
event: [ event: [
{ {
directory?: string directory?: string
project?: string
workspace?: string
payload: any payload: any
}, },
] ]
+6 -1
View File
@@ -1,9 +1,9 @@
import z from "zod" import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect" import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event" import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global" import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -91,8 +91,13 @@ export namespace Bus {
yield* PubSub.publish(s.wildcard, payload) yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: dir, directory: dir,
project: context.project.id,
workspace,
payload, payload,
}) })
}) })
+2 -1
View File
@@ -21,6 +21,7 @@ import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models" import { ModelsDev } from "../../provider/models"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share/session"
import { Session } from "../../session" import { Session } from "../../session"
import type { SessionID } from "../../session/schema" import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema"
@@ -559,7 +560,7 @@ export const GithubRunCommand = cmd({
shareId = await (async () => { shareId = await (async () => {
if (share === false) return if (share === false) return
if (!share && repoData.data.private) return if (!share && repoData.data.private) return
await Session.share(session.id) await SessionShare.share(session.id)
return session.id.slice(-8) return session.id.slice(-8)
})() })()
console.log("opencode session", session.id) console.log("opencode session", session.id)
+1
View File
@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
clientId: oauthConfig?.clientId, clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret, clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope, scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
}, },
{ {
onRedirect: async () => {}, onRedirect: async () => {},
+33 -32
View File
@@ -14,7 +14,6 @@ import {
batch, batch,
Show, Show,
on, on,
onCleanup,
} from "solid-js" } from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component" import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading" import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProvider, useSync } from "@tui/context/sync"
@@ -54,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args" import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
@@ -216,27 +216,29 @@ export function tui(input: {
headers={input.headers} headers={input.headers}
events={input.events} events={input.events}
> >
<SyncProvider> <ProjectProvider>
<ThemeProvider mode={mode}> <SyncProvider>
<LocalProvider> <ThemeProvider mode={mode}>
<KeybindProvider> <LocalProvider>
<PromptStashProvider> <KeybindProvider>
<DialogProvider> <PromptStashProvider>
<CommandProvider> <DialogProvider>
<FrecencyProvider> <CommandProvider>
<PromptHistoryProvider> <FrecencyProvider>
<PromptRefProvider> <PromptHistoryProvider>
<App onSnapshot={input.onSnapshot} /> <PromptRefProvider>
</PromptRefProvider> <App onSnapshot={input.onSnapshot} />
</PromptHistoryProvider> </PromptRefProvider>
</FrecencyProvider> </PromptHistoryProvider>
</CommandProvider> </FrecencyProvider>
</DialogProvider> </CommandProvider>
</PromptStashProvider> </DialogProvider>
</KeybindProvider> </PromptStashProvider>
</LocalProvider> </KeybindProvider>
</ThemeProvider> </LocalProvider>
</SyncProvider> </ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider> </SDKProvider>
</TuiConfigProvider> </TuiConfigProvider>
</RouteProvider> </RouteProvider>
@@ -260,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const kv = useKV() const kv = useKV()
const command = useCommandDialog() const command = useCommandDialog()
const keybind = useKeybind() const keybind = useKeybind()
const event = useEvent()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const themeState = useTheme() const themeState = useTheme()
@@ -283,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
route, route,
routes, routes,
bump: () => setRouteRev((x) => x + 1), bump: () => setRouteRev((x) => x + 1),
event,
sdk, sdk,
sync, sync,
theme: themeState, theme: themeState,
@@ -491,12 +495,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const current = promptRef.current const current = promptRef.current
// Don't require focus - if there's any text, preserve it // Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined const currentPrompt = current?.current?.input ? current.current : undefined
const workspaceID =
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({ route.navigate({
type: "home", type: "home",
initialPrompt: currentPrompt, initialPrompt: currentPrompt,
workspaceID,
}) })
dialog.clear() dialog.clear()
}, },
@@ -806,11 +807,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}, },
]) ])
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command) command.trigger(evt.properties.command)
}) })
sdk.event.on(TuiEvent.ToastShow.type, (evt) => { event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({ toast.show({
title: evt.properties.title, title: evt.properties.title,
message: evt.properties.message, message: evt.properties.message,
@@ -819,14 +820,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
}) })
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => { event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({ route.navigate({
type: "session", type: "session",
sessionID: evt.properties.sessionID, sessionID: evt.properties.sessionID,
}) })
}) })
sdk.event.on("session.deleted", (evt) => { event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" }) route.navigate({ type: "home" })
toast.show({ toast.show({
@@ -836,7 +837,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
} }
}) })
sdk.event.on("session.error", (evt) => { event.on("session.error", (evt) => {
const error = evt.properties.error const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error) const message = errorMessage(error)
@@ -848,7 +849,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
}) })
sdk.event.on("installation.update-available", async (evt) => { event.on("installation.update-available", async (evt) => {
const version = evt.properties.version const version = evt.properties.version
const skipped = kv.get("skipped_version") const skipped = kv.get("skipped_version")
@@ -1,5 +1,6 @@
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js" import { createEffect, createMemo, createSignal, onMount } from "solid-js"
@@ -14,7 +15,7 @@ function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>
return createOpencodeClient({ return createOpencodeClient({
baseUrl: sdk.url, baseUrl: sdk.url,
fetch: sdk.fetch, fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory, directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID, experimental_workspaceID: workspaceID,
}) })
} }
@@ -149,6 +150,7 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi
export function DialogWorkspaceList() { export function DialogWorkspaceList() {
const dialog = useDialog() const dialog = useDialog()
const project = useProject()
const route = useRoute() const route = useRoute()
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
@@ -168,8 +170,9 @@ export function DialogWorkspaceList() {
forceCreate, forceCreate,
}) })
async function selectWorkspace(workspaceID: string) { async function selectWorkspace(workspaceID: string | null) {
if (workspaceID === "__local__") { if (workspaceID == null) {
project.workspace.set(undefined)
if (localCount() > 0) { if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />) dialog.replace(() => <DialogSessionList localOnly={true} />)
return return
@@ -199,12 +202,7 @@ export function DialogWorkspaceList() {
await open(workspaceID) await open(workspaceID)
} }
const currentWorkspaceID = createMemo(() => { const currentWorkspaceID = createMemo(() => project.workspace.current())
if (route.data.type === "session") {
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
}
return "__local__"
})
const localCount = createMemo( const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length, () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
@@ -234,7 +232,7 @@ export function DialogWorkspaceList() {
const options = createMemo(() => [ const options = createMemo(() => [
{ {
title: "Local", title: "Local",
value: "__local__", value: null,
category: "Workspace", category: "Workspace",
description: "Use the local machine", description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`, footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
@@ -292,7 +290,7 @@ export function DialogWorkspaceList() {
keybind: keybind.all.session_delete?.[0], keybind: keybind.all.session_delete?.[0],
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: async (option) => {
if (option.value === "__create__" || option.value === "__local__") return if (option.value === "__create__" || option.value === null) return
if (toDelete() !== option.value) { if (toDelete() !== option.value) {
setToDelete(option.value) setToDelete(option.value)
return return
@@ -307,6 +305,7 @@ export function DialogWorkspaceList() {
return return
} }
if (currentWorkspaceID() === option.value) { if (currentWorkspaceID() === option.value) {
project.workspace.set(undefined)
route.navigate({ route.navigate({
type: "home", type: "home",
}) })
@@ -250,7 +250,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...sortedFiles.map((item): AutocompleteOption => { ...sortedFiles.map((item): AutocompleteOption => {
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "") const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}` const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath) const urlObj = pathToFileURL(fullPath)
let filename = item let filename = item
@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema" import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
const agentStyleId = syntax().getStyleId("extmark.agent")! const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")! const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0 let promptPartTypeId = 0
const event = useEvent()
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { event.on(TuiEvent.PromptAppend.type, (evt) => {
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
input.insertText(evt.properties.text) input.insertText(evt.properties.text)
setTimeout(() => { setTimeout(() => {
@@ -1,11 +1,13 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useSync } from "./sync" import { useSync } from "./sync"
import { Global } from "@/global" import { Global } from "@/global"
export function useDirectory() { export function useDirectory() {
const project = useProject()
const sync = useSync() const sync = useSync()
return createMemo(() => { return createMemo(() => {
const directory = sync.data.path.directory || process.cwd() const directory = project.instance.path().directory || process.cwd()
const result = directory.replace(Global.Path.home, "~") const result = directory.replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result return result
@@ -0,0 +1,41 @@
import type { Event } from "@opencode-ai/sdk/v2"
import { useProject } from "./project"
import { useSDK } from "./sdk"
export function useEvent() {
const project = useProject()
const sdk = useSDK()
function subscribe(handler: (event: Event) => void) {
return sdk.event.on("event", (event) => {
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
}
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
if (event.directory === project.instance.directory()) {
handler(event.payload)
}
})
}
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
return subscribe((event) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>)
})
}
return {
subscribe,
on,
}
}
@@ -0,0 +1,65 @@
import { batch } from "solid-js"
import type { Path } from "@opencode-ai/sdk"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
const sdk = useSDK()
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
path: {
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
},
workspace: undefined as string | undefined,
})
async function sync() {
const workspace = store.workspace
const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
])
batch(() => {
setStore("instance", "path", reconcile(path.data!))
setStore("project", "id", project.data?.id)
})
}
return {
data: store,
project() {
return store.project.id
},
instance: {
path() {
return store.instance.path
},
directory() {
return store.instance.path.directory
},
},
workspace: {
current() {
return store.workspace
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace === workspace) return
setStore("workspace", workspace)
},
},
sync,
}
},
})
@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = { export type HomeRoute = {
type: "home" type: "home"
initialPrompt?: PromptInfo initialPrompt?: PromptInfo
workspaceID?: string
} }
export type SessionRoute = { export type SessionRoute = {
@@ -1,10 +1,11 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = { export type EventSource = {
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void> subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
} }
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let sdk = createSDK() let sdk = createSDK()
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }> event: GlobalEvent
}>() }>()
let queue: Event[] = [] let queue: GlobalEvent[] = []
let timer: Timer | undefined let timer: Timer | undefined
let last = 0 let last = 0
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Batch all event emissions so all store updates result in a single render // Batch all event emissions so all store updates result in a single render
batch(() => { batch(() => {
for (const event of events) { for (const event of events) {
emitter.emit(event.type, event) emitter.emit("event", event)
} }
}) })
} }
const handleEvent = (event: Event) => { const handleEvent = (event: GlobalEvent) => {
queue.push(event) queue.push(event)
const elapsed = Date.now() - last const elapsed = Date.now() - last
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
;(async () => { ;(async () => {
while (true) { while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.event.subscribe({}, { signal: ctrl.signal }) const events = await sdk.global.event({ signal: ctrl.signal })
for await (const event of events.stream) { for await (const event of events.stream) {
if (ctrl.signal.aborted) break if (ctrl.signal.aborted) break
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onMount(async () => { onMount(async () => {
if (props.events) { if (props.events) {
const unsub = await props.events.subscribe(props.directory, handleEvent) const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub) onCleanup(unsub)
} else { } else {
startSSE() startSSE()
@@ -17,18 +17,19 @@ import type {
ProviderListResponse, ProviderListResponse,
ProviderAuthMethod, ProviderAuthMethod,
VcsInfo, VcsInfo,
Workspace,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot" import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit" import { useExit } from "./exit"
import { useArgs } from "./args" import { useArgs } from "./args"
import { batch, onMount } from "solid-js" import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
@@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpResource [key: string]: McpResource
} }
formatter: FormatterStatus[] formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
workspaceList: Workspace[] workspaceList: Workspace[]
vcs: VcsInfo | undefined
}>({ }>({
provider_next: { provider_next: {
all: [], all: [],
@@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {}, mcp: {},
mcp_resource: {}, mcp_resource: {},
formatter: [], formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
workspaceList: [], workspaceList: [],
vcs: undefined,
}) })
const event = useEvent()
const project = useProject()
const sdk = useSDK() const sdk = useSDK()
async function syncWorkspaces() { async function syncWorkspaces() {
const workspace = project.workspace.current()
const result = await sdk.client.experimental.workspace.list().catch(() => undefined) const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return if (!result?.data) return
setStore("workspaceList", reconcile(result.data)) setStore("workspaceList", reconcile(result.data))
if (!result.data.some((item) => item.id === workspace)) {
project.workspace.set(undefined)
}
} }
sdk.event.listen((e) => { event.subscribe((event) => {
const event = e.details
switch (event.type) { switch (event.type) {
case "server.instance.disposed": case "server.instance.disposed":
bootstrap() bootstrap()
@@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
case "lsp.updated": { case "lsp.updated": {
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)) const workspace = project.workspace.current()
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
break break
} }
@@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap() { async function bootstrap() {
console.log("bootstrapping") console.log("bootstrapping")
const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session const sessionListPromise = sdk.client.session
.list({ start: start }) .list({ start: start, workspace })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session // blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({}, { throwOnError: true }) const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true }) const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console const consoleStatePromise = sdk.client.experimental.console
.get({}, { throwOnError: true }) .get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data)) .then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState) .catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true }) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({}, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [ const blockingRequests: Promise<unknown>[] = [
providersPromise, providersPromise,
providerListPromise, providerListPromise,
agentsPromise, agentsPromise,
configPromise, configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []), ...(args.continue ? [sessionListPromise] : []),
] ]
@@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
Promise.all([ Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), sdk.client.experimental.resource
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), .list({ workspace })
sdk.client.session.status().then((x) => { .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data!)) setStore("session_status", reconcile(x.data!))
}), }),
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
syncWorkspaces(), syncWorkspaces(),
]).then(() => { ]).then(() => {
setStore("status", "complete") setStore("status", "complete")
@@ -449,11 +458,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}) })
} }
onMount(() => {
bootstrap()
})
const fullSyncedSessions = new Set<string>() const fullSyncedSessions = new Set<string>()
createEffect(
on(
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
},
),
)
const result = { const result = {
data: store, data: store,
set: setStore, set: setStore,
@@ -463,6 +478,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() { get ready() {
return store.status !== "loading" return store.status !== "loading"
}, },
get path() {
return project.instance.path()
},
session: { session: {
get(sessionID: string) { get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id) const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -481,11 +499,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}, },
async sync(sessionID: string) { async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return if (fullSyncedSessions.has(sessionID)) return
const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([ const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }), sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }), sdk.client.session.messages({ sessionID, limit: 100, workspace }),
sdk.client.session.todo({ sessionID }), sdk.client.session.todo({ sessionID, workspace }),
sdk.client.session.diff({ sessionID }), sdk.client.session.diff({ sessionID, workspace }),
]) ])
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -504,8 +523,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}, },
}, },
workspace: { workspace: {
list() {
return store.workspaceList
},
get(workspaceID: string) { get(workspaceID: string) {
return store.workspaceList.find((workspace) => workspace.id === workspaceID) return store.workspaceList.find((item) => item.id === workspaceID)
}, },
sync: syncWorkspaces, sync: syncWorkspaces,
}, },
@@ -1,6 +1,7 @@
import type { ParsedKey } from "@opentui/core" import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command" import type { useCommandDialog } from "@tui/component/dialog-command"
import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind" import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route" import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk" import type { useSDK } from "@tui/context/sdk"
@@ -36,6 +37,7 @@ type Input = {
route: ReturnType<typeof useRoute> route: ReturnType<typeof useRoute>
routes: RouteMap routes: RouteMap
bump: () => void bump: () => void
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK> sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
theme: ReturnType<typeof useTheme> theme: ReturnType<typeof useTheme>
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.provider return sync.data.provider
}, },
get path() { get path() {
return sync.data.path return sync.path
}, },
get vcs() { get vcs() {
if (!sync.data.vcs) return if (!sync.data.vcs) return
@@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
get client() { get client() {
return input.sdk.client return input.sdk.client
}, },
event: input.sdk.event, event: input.event,
renderer: input.renderer, renderer: input.renderer,
slots: { slots: {
register() { register() {
@@ -1,6 +1,7 @@
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js" import { createEffect, createSignal } from "solid-js"
import { Logo } from "../component/logo" import { Logo } from "../component/logo"
import { useProject } from "../context/project"
import { useSync } from "../context/sync" import { useSync } from "../context/sync"
import { Toast } from "../ui/toast" import { Toast } from "../ui/toast"
import { useArgs } from "../context/args" import { useArgs } from "../context/args"
@@ -18,6 +19,7 @@ const placeholder = {
export function Home() { export function Home() {
const sync = useSync() const sync = useSync()
const project = useProject()
const route = useRouteData("home") const route = useRouteData("home")
const promptRef = usePromptRef() const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>() const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -63,11 +65,16 @@ export function Home() {
</box> </box>
<box height={1} minHeight={0} flexShrink={1} /> <box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}> <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}> <TuiPluginRuntime.Slot
name="home_prompt"
mode="replace"
workspace_id={project.workspace.current()}
ref={bind}
>
<Prompt <Prompt
ref={bind} ref={bind}
workspaceID={route.workspaceID} workspaceID={project.workspace.current()}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />} right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
placeholders={placeholder} placeholders={placeholder}
/> />
</TuiPluginRuntime.Slot> </TuiPluginRuntime.Slot>
@@ -15,7 +15,9 @@ import {
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import path from "path" import path from "path"
import { useRoute, useRouteData } from "@tui/context/route" import { useRoute, useRouteData } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme" import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -116,6 +118,8 @@ export function Session() {
const route = useRouteData("session") const route = useRouteData("session")
const { navigate } = useRoute() const { navigate } = useRoute()
const sync = useSync() const sync = useSync()
const event = useEvent()
const project = useProject()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const kv = useKV() const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
@@ -155,7 +159,7 @@ export function Session() {
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
@@ -172,10 +176,16 @@ export function Session() {
const providers = createMemo(() => Model.index(sync.data.provider)) const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
createEffect(async () => { createEffect(async () => {
await sync.session await sdk.client.session
.sync(route.sessionID) .get({ sessionID: route.sessionID }, { throwOnError: true })
.then((x) => {
project.workspace.set(x.data?.workspaceID)
})
.then(() => sync.session.sync(route.sessionID))
.then(() => { .then(() => {
if (scroll) scroll.scrollBy(100_000) if (scroll) scroll.scrollBy(100_000)
}) })
@@ -189,13 +199,10 @@ export function Session() {
}) })
}) })
const toast = useToast()
const sdk = useSDK()
// Handle initial prompt from fork // Handle initial prompt from fork
let seeded = false let seeded = false
let lastSwitch: string | undefined = undefined let lastSwitch: string | undefined = undefined
sdk.event.on("message.part.updated", (evt) => { event.on("message.part.updated", (evt) => {
const part = evt.properties.part const part = evt.properties.part
if (part.type !== "tool") return if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return if (part.sessionID !== route.sessionID) return
@@ -224,7 +231,7 @@ export function Session() {
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
sdk.event.on("session.status", (evt) => { event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return if (evt.properties.status.type !== "retry") return
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
@@ -1791,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
const workdir = props.input.workdir const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined if (!workdir || workdir === ".") return undefined
const base = sync.data.path.directory const base = sync.path.directory
if (!base) return undefined if (!base) return undefined
const absolute = path.resolve(base, workdir) const absolute = path.resolve(base, workdir)
+4 -12
View File
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource { function createEventSource(client: RpcClient): EventSource {
return { return {
subscribe: async (directory, handler) => { subscribe: async (handler) => {
const id = await client.call("subscribe", { directory }) return client.on<GlobalEvent>("global.event", (e) => {
const unsub = client.on<{ id: string; event: Event }>("event", (e) => { handler(e)
if (e.id === id) {
handler(e.event)
}
}) })
return () => {
unsub()
client.call("unsubscribe", { id })
}
}, },
} }
} }
+1 -95
View File
@@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import type { Event } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { WorkspaceID } from "@/control-plane/schema"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
await Log.init({ await Log.init({
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
let server: Awaited<ReturnType<typeof Server.listen>> | undefined let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStreams = new Map<string, AbortController>()
function startEventStream(directory: string) {
const id = crypto.randomUUID()
const abort = new AbortController()
const signal = abort.signal
eventStreams.set(id, abort)
async function run() {
while (!signal.aborted) {
const shouldReconnect = await Instance.provide({
directory,
init: InstanceBootstrap,
fn: () =>
new Promise<boolean>((resolve) => {
Rpc.emit("event", {
type: "server.connected",
properties: {},
} satisfies Event)
let settled = false
const settle = (value: boolean) => {
if (settled) return
settled = true
signal.removeEventListener("abort", onAbort)
unsub()
resolve(value)
}
const unsub = Bus.subscribeAll((event) => {
Rpc.emit("event", {
id,
event: event as Event,
})
if (event.type === Bus.InstanceDisposed.type) {
settle(true)
}
})
const onAbort = () => {
settle(false)
}
signal.addEventListener("abort", onAbort, { once: true })
}),
}).catch((error) => {
Log.Default.error("event stream subscribe error", {
error: error instanceof Error ? error.message : error,
})
return false
})
if (!shouldReconnect || signal.aborted) {
break
}
if (!signal.aborted) {
await sleep(250)
}
}
}
run().catch((error) => {
Log.Default.error("event stream error", {
error: error instanceof Error ? error.message : error,
})
})
return id
}
function stopEventStream(id: string) {
const abortController = eventStreams.get(id)
if (!abortController) return
abortController.abort()
eventStreams.delete(id)
}
export const rpc = { export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) { async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers } const headers = { ...input.headers }
@@ -167,19 +83,9 @@ export const rpc = {
async reload() { async reload() {
await Config.invalidate(true) await Config.invalidate(true)
}, },
async subscribe(input: { directory: string | undefined }) {
return startEventStream(input.directory || process.cwd())
},
async unsubscribe(input: { id: string }) {
stopEventStream(input.id)
},
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
for (const id of [...eventStreams.keys()]) {
stopEventStream(id)
}
await Instance.disposeAll() await Instance.disposeAll()
if (server) await server.stop(true) if (server) await server.stop(true)
}, },
+4
View File
@@ -399,6 +399,10 @@ export namespace Config {
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"), scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
}) })
.strict() .strict()
.meta({ .meta({
@@ -0,0 +1,22 @@
import { Context } from "../util/context"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
workspaceID: string
}
const context = Context.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
},
get workspaceID() {
try {
return context.use().workspaceID
} catch (err) {
return undefined
}
},
}
@@ -134,12 +134,12 @@ export namespace Workspace {
continue continue
} }
await parseSSE(res.body, stop, (event) => { // await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", { // GlobalBus.emit("event", {
directory: space.id, // directory: space.id,
payload: event, // payload: event,
}) // })
}) // })
// Wait 250ms and retry if SSE connection fails // Wait 250ms and retry if SSE connection fails
await sleep(250) await sleep(250)
@@ -499,4 +499,3 @@ const rt = lazy(async () => {
type RT = Awaited<ReturnType<typeof rt>> type RT = Awaited<ReturnType<typeof rt>>
export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any])) export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any]))
export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any]))
@@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance"
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", { export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined, defaultValue: () => undefined,
}) })
export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined,
})
@@ -1,8 +1,9 @@
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect" import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
import { Instance, type InstanceContext } from "@/project/instance" import { Instance, type InstanceContext } from "@/project/instance"
import { Context } from "@/util/context" import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context"
const TypeId = "~opencode/InstanceState" const TypeId = "~opencode/InstanceState"
@@ -28,6 +29,10 @@ export namespace InstanceState {
return (yield* InstanceRef) ?? Instance.current return (yield* InstanceRef) ?? Instance.current
}) })
export const workspaceID = Effect.gen(function* () {
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
})
export const directory = Effect.map(context, (ctx) => ctx.directory) export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = <A, E = never, R = never>( export const make = <A, E = never, R = never>(
@@ -73,10 +78,4 @@ export namespace InstanceState {
Effect.gen(function* () { Effect.gen(function* () {
return yield* ScopedCache.invalidate(self.cache, yield* directory) return yield* ScopedCache.invalidate(self.cache, yield* directory)
}) })
/**
* Effect finalizers run on the fiber scheduler after the original async
* boundary, so ALS reads like Instance.directory can be gone by then.
*/
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
} }
+4 -2
View File
@@ -2,15 +2,17 @@ import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap" import * as ServiceMap from "effect/ServiceMap"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Context } from "@/util/context" import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./oltp" import { Observability } from "./oltp"
import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try { try {
const ctx = Instance.current const ctx = Instance.current
return Effect.provideService(effect, InstanceRef, ctx) const workspaceID = WorkspaceContext.workspaceID
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
} catch (err) { } catch (err) {
if (!(err instanceof Context.NotFound)) throw err if (!(err instanceof Context.NotFound)) throw err
} }
+6 -14
View File
@@ -1,10 +1,10 @@
import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
export interface Runner<A, E = never> { export interface Runner<A, E = never> {
readonly state: Runner.State<A, E> readonly state: Runner.State<A, E>
readonly busy: boolean readonly busy: boolean
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E> readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly startShell: (work: (signal: AbortSignal) => Effect.Effect<A, E>) => Effect.Effect<A, E> readonly startShell: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly cancel: Effect.Effect<void> readonly cancel: Effect.Effect<void>
} }
@@ -20,7 +20,6 @@ export namespace Runner {
interface ShellHandle<A, E> { interface ShellHandle<A, E> {
id: number id: number
fiber: Fiber.Fiber<A, E> fiber: Fiber.Fiber<A, E>
abort: AbortController
} }
interface PendingHandle<A, E> { interface PendingHandle<A, E> {
@@ -100,13 +99,7 @@ export namespace Runner {
}), }),
).pipe(Effect.flatten) ).pipe(Effect.flatten)
const stopShell = (shell: ShellHandle<A, E>) => const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
Effect.gen(function* () {
shell.abort.abort()
const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis"))
if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber)
yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid)
})
const ensureRunning = (work: Effect.Effect<A, E>) => const ensureRunning = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect( SynchronizedRef.modifyEffect(
@@ -138,7 +131,7 @@ export namespace Runner {
), ),
) )
const startShell = (work: (signal: AbortSignal) => Effect.Effect<A, E>) => const startShell = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect( SynchronizedRef.modifyEffect(
ref, ref,
Effect.fnUntraced(function* (st) { Effect.fnUntraced(function* (st) {
@@ -153,9 +146,8 @@ export namespace Runner {
} }
yield* busy yield* busy
const id = next() const id = next()
const abort = new AbortController() const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) const shell = { id, fiber } satisfies ShellHandle<A, E>
const shell = { id, fiber, abort } satisfies ShellHandle<A, E>
return [ return [
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* Fiber.await(fiber) const exit = yield* Fiber.await(fiber)
+88 -105
View File
@@ -11,7 +11,6 @@ import path from "path"
import z from "zod" import z from "zod"
import { Global } from "../global" import { Global } from "../global"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Protected } from "./protected" import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
@@ -344,6 +343,7 @@ export namespace File {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service const appFs = yield* AppFileSystem.Service
const git = yield* Git.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() => Effect.fn("File.state")(() =>
@@ -410,6 +410,10 @@ export namespace File {
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
}) })
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: Instance.directory })).text()
})
const init = Effect.fn("File.init")(function* () { const init = Effect.fn("File.init")(function* () {
yield* ensure() yield* ensure()
}) })
@@ -417,100 +421,87 @@ export namespace File {
const status = Effect.fn("File.status")(function* () { const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return [] if (Instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => { const diffOutput = yield* gitText([
const diffOutput = ( "-c",
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { "core.fsmonitor=false",
cwd: Instance.directory, "-c",
"core.quotepath=false",
"diff",
"--numstat",
"HEAD",
])
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
}) })
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
} }
}
const untrackedOutput = ( const untrackedOutput = yield* gitText([
await Git.run( "-c",
[ "core.fsmonitor=false",
"-c", "-c",
"core.fsmonitor=false", "core.quotepath=false",
"-c", "ls-files",
"core.quotepath=false", "--others",
"ls-files", "--exclude-standard",
"--others", ])
"--exclude-standard",
],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) { for (const file of untrackedOutput.trim().split("\n")) {
try { const content = yield* appFs
const content = await Filesystem.readText(path.join(Instance.directory, file)) .readFileString(path.join(Instance.directory, file))
changed.push({ .pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
path: file, if (content === undefined) continue
added: content.split("\n").length, changed.push({
removed: 0, path: file,
status: "added", added: content.split("\n").length,
}) removed: 0,
} catch { status: "added",
continue })
}
}
} }
}
const deletedOutput = ( const deletedOutput = yield* gitText([
await Git.run( "-c",
[ "core.fsmonitor=false",
"-c", "-c",
"core.fsmonitor=false", "core.quotepath=false",
"-c", "diff",
"core.quotepath=false", "--name-only",
"diff", "--diff-filter=D",
"--name-only", "HEAD",
"--diff-filter=D", ])
"HEAD",
],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) { if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) { for (const file of deletedOutput.trim().split("\n")) {
changed.push({ changed.push({
path: file, path: file,
added: 0, added: 0,
removed: 0, removed: 0,
status: "deleted", status: "deleted",
}) })
}
} }
}
return changed.map((item) => { return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return { return {
...item, ...item,
path: path.relative(Instance.directory, full), path: path.relative(Instance.directory, full),
} }
})
}) })
}) })
const read = Effect.fn("File.read")(function* (file: string) { const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const full = path.join(Instance.directory, file) const full = path.join(Instance.directory, file)
@@ -558,27 +549,19 @@ export namespace File {
) )
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
return yield* Effect.promise(async (): Promise<File.Content> => { let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
let diff = ( if (!diff.trim()) {
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
).text() }
if (!diff.trim()) { if (diff.trim()) {
diff = ( const original = yield* git.show(Instance.directory, "HEAD", file)
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { const patch = structuredPatch(file, file, original, content, "old", "new", {
cwd: Instance.directory, context: Infinity,
}) ignoreWhitespace: true,
).text() })
} return { type: "text" as const, content, patch, diff: formatPatch(patch) }
if (diff.trim()) { }
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() return { type: "text" as const, content }
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return { type: "text", content, patch, diff: formatPatch(patch) }
}
return { type: "text", content }
})
} }
return { type: "text" as const, content } return { type: "text" as const, content }
@@ -660,7 +643,7 @@ export namespace File {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+5 -6
View File
@@ -71,6 +71,7 @@ export namespace FileWatcher {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")( Effect.fn("FileWatcher.state")(
@@ -131,11 +132,9 @@ export namespace FileWatcher {
} }
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() => const result = yield* git.run(["rev-parse", "--git-dir"], {
Git.run(["rev-parse", "--git-dir"], { cwd: Instance.project.worktree,
cwd: Instance.project.worktree, })
}),
)
const vcsDir = const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
@@ -161,7 +160,7 @@ export namespace FileWatcher {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
-32
View File
@@ -265,39 +265,7 @@ export namespace Git {
return runPromise((git) => git.run(args, opts)) return runPromise((git) => git.run(args, opts))
} }
export async function branch(cwd: string) {
return runPromise((git) => git.branch(cwd))
}
export async function prefix(cwd: string) {
return runPromise((git) => git.prefix(cwd))
}
export async function defaultBranch(cwd: string) { export async function defaultBranch(cwd: string) {
return runPromise((git) => git.defaultBranch(cwd)) return runPromise((git) => git.defaultBranch(cwd))
} }
export async function hasHead(cwd: string) {
return runPromise((git) => git.hasHead(cwd))
}
export async function mergeBase(cwd: string, base: string, head?: string) {
return runPromise((git) => git.mergeBase(cwd, base, head))
}
export async function show(cwd: string, ref: string, file: string, prefix?: string) {
return runPromise((git) => git.show(cwd, ref, file, prefix))
}
export async function status(cwd: string) {
return runPromise((git) => git.status(cwd))
}
export async function diff(cwd: string, ref: string) {
return runPromise((git) => git.diff(cwd, ref))
}
export async function stats(cwd: string, ref: string) {
return runPromise((git) => git.stats(cwd, ref))
}
} }
+1 -11
View File
@@ -105,17 +105,7 @@ export namespace LSPServer {
if (!tsserver) return if (!tsserver) return
const bin = await Npm.which("typescript-language-server") const bin = await Npm.which("typescript-language-server")
if (!bin) return if (!bin) return
const proc = spawn(bin, ["--stdio"], {
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
if (
!(await pathExists(path.join(root, "tsconfig.json"))) &&
!(await pathExists(path.join(root, "jsconfig.json")))
) {
args.push("--ignore-node-modules")
}
const proc = spawn(bin, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
+7 -5
View File
@@ -286,6 +286,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId, clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret, clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope, scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
}, },
{ {
onRedirect: async (url) => { onRedirect: async (url) => {
@@ -716,13 +717,16 @@ export namespace MCP {
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
yield* Effect.promise(() => McpOAuthCallback.ensureRunning()) // OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
// Start the callback server with custom redirectUri if configured
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0")) .map((b) => b.toString(16).padStart(2, "0"))
.join("") .join("")
yield* auth.updateOAuthState(mcpName, oauthState) yield* auth.updateOAuthState(mcpName, oauthState)
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
let capturedUrl: URL | undefined let capturedUrl: URL | undefined
const authProvider = new McpOAuthProvider( const authProvider = new McpOAuthProvider(
mcpName, mcpName,
@@ -731,6 +735,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId, clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret, clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope, scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
}, },
{ {
onRedirect: async (url) => { onRedirect: async (url) => {
@@ -901,9 +906,6 @@ export namespace MCP {
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name)) export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
export const getPrompt = async (clientName: string, name: string, args?: Record<string, string>) =>
runPromise((svc) => svc.getPrompt(clientName, name, args))
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName)) export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName)) export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
+26 -10
View File
@@ -1,10 +1,14 @@
import { createConnection } from "net" import { createConnection } from "net"
import { createServer } from "http" import { createServer } from "http"
import { Log } from "../util/log" import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
const log = Log.create({ service: "mcp.oauth-callback" }) const log = Log.create({ service: "mcp.oauth-callback" })
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH
const HTML_SUCCESS = `<!DOCTYPE html> const HTML_SUCCESS = `<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -71,9 +75,9 @@ export namespace McpOAuthCallback {
} }
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`) const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) { if (url.pathname !== currentPath) {
res.writeHead(404) res.writeHead(404)
res.end("Not found") res.end("Not found")
return return
@@ -135,19 +139,31 @@ export namespace McpOAuthCallback {
res.end(HTML_SUCCESS) res.end(HTML_SUCCESS)
} }
export async function ensureRunning(): Promise<void> { export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided)
const { port, path } = parseRedirectUri(redirectUri)
// If server is running on a different port/path, stop it first
if (server && (currentPort !== port || currentPath !== path)) {
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
await stop()
}
if (server) return if (server) return
const running = await isPortInUse() const running = await isPortInUse(port)
if (running) { if (running) {
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) log.info("oauth callback server already running on another instance", { port })
return return
} }
currentPort = port
currentPath = path
server = createServer(handleRequest) server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
server!.listen(OAUTH_CALLBACK_PORT, () => { server!.listen(currentPort, () => {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) log.info("oauth callback server started", { port: currentPort, path: currentPath })
resolve() resolve()
}) })
server!.on("error", reject) server!.on("error", reject)
@@ -182,9 +198,9 @@ export namespace McpOAuthCallback {
} }
} }
export async function isPortInUse(): Promise<boolean> { export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1") const socket = createConnection(port, "127.0.0.1")
socket.on("connect", () => { socket.on("connect", () => {
socket.destroy() socket.destroy()
resolve(true) resolve(true)
@@ -17,6 +17,7 @@ export interface McpOAuthConfig {
clientId?: string clientId?: string
clientSecret?: string clientSecret?: string
scope?: string scope?: string
redirectUri?: string
} }
export interface McpOAuthCallbacks { export interface McpOAuthCallbacks {
@@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
) {} ) {}
get redirectUrl(): string { get redirectUrl(): string {
if (this.config.redirectUri) {
return this.config.redirectUri
}
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
} }
@@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider {
} }
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
/**
* Parse a redirect URI to extract port and path for the callback server.
* Returns defaults if the URI can't be parsed.
*/
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
if (!redirectUri) {
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
}
try {
const url = new URL(redirectUri)
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
const path = url.pathname || OAUTH_CALLBACK_PATH
return { port, path }
} catch {
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
}
}
+2 -2
View File
@@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
"gpt-5.4", "gpt-5.4",
"gpt-5.4-mini", "gpt-5.4-mini",
]) ])
for (const modelId of Object.keys(provider.models)) { for (const [modelId, model] of Object.entries(provider.models)) {
if (modelId.includes("codex")) continue if (modelId.includes("codex")) continue
if (allowedModels.has(modelId)) continue if (allowedModels.has(model.api.id)) continue
delete provider.models[modelId] delete provider.models[modelId]
} }
+30 -14
View File
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Context } from "../util/context" import { Context } from "../util/context"
import { Project } from "./project" import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { State } from "./state" import { State } from "./state"
export interface InstanceContext { export interface InstanceContext {
@@ -20,19 +21,9 @@ const disposal = {
all: undefined as Promise<void> | undefined, all: undefined as Promise<void> | undefined,
} }
function emit(directory: string) { function emitDisposed(directory: string) {}
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
return iife(async () => { return iife(async () => {
const ctx = const ctx =
input.project && input.worktree input.project && input.worktree
@@ -93,6 +84,7 @@ export const Instance = {
get project() { get project() {
return context.use().project return context.use().project
}, },
/** /**
* Check if a path is within the project boundary. * Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree. * Returns true if path is inside Instance.directory OR Instance.worktree.
@@ -131,15 +123,39 @@ export const Instance = {
await Promise.all([State.dispose(directory), disposeInstance(directory)]) await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory) cache.delete(directory)
const next = track(directory, boot({ ...input, directory })) const next = track(directory, boot({ ...input, directory }))
emit(directory)
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return await next return await next
}, },
async dispose() { async dispose() {
const directory = Instance.directory const directory = Instance.directory
const project = Instance.project
Log.Default.info("disposing instance", { directory }) Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)]) await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory) cache.delete(directory)
emit(directory)
GlobalBus.emit("event", {
directory,
project: project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}, },
async disposeAll() { async disposeAll() {
if (disposal.all) return disposal.all if (disposal.all) return disposal.all
+2
View File
@@ -137,6 +137,8 @@ export namespace Project {
const emitUpdated = (data: Info) => const emitUpdated = (data: Info) =>
Effect.sync(() => Effect.sync(() =>
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: "global",
project: data.id,
payload: { type: Event.Updated.type, properties: data }, payload: { type: Event.Updated.type, properties: data },
}), }),
) )
+28 -30
View File
@@ -161,39 +161,37 @@ export namespace Vcs {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Vcs.state")((ctx) => Effect.fn("Vcs.state")(function* (ctx) {
Effect.gen(function* () { if (ctx.project.vcs !== "git") {
if (ctx.project.vcs !== "git") { return { current: undefined, root: undefined }
return { current: undefined, root: undefined } }
}
const get = Effect.fnUntraced(function* () { const get = Effect.fnUntraced(function* () {
return yield* git.branch(ctx.directory) return yield* git.branch(ctx.directory)
}) })
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2, concurrency: 2,
}) })
const value = { current, root } const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name }) log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* bus.subscribe(FileWatcher.Event.Updated).pipe( yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) => Stream.runForEach((_evt) =>
Effect.gen(function* () { Effect.gen(function* () {
const next = yield* get() const next = yield* get()
if (next !== value.current) { if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next }) log.info("branch changed", { from: value.current, to: next })
value.current = next value.current = next
yield* bus.publish(Event.BranchUpdated, { branch: next }) yield* bus.publish(Event.BranchUpdated, { branch: next })
} }
}), }),
), ),
Effect.forkScoped, Effect.forkScoped,
) )
return value return value
}), }),
),
) )
return Service.of({ return Service.of({
+40 -17
View File
@@ -22,6 +22,27 @@ export namespace ModelsDev {
) )
const ttl = 5 * 60 * 1000 const ttl = 5 * 60 * 1000
type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
)
const Cost = z.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
export const Model = z.object({ export const Model = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
@@ -41,22 +62,7 @@ export namespace ModelsDev {
.strict(), .strict(),
]) ])
.optional(), .optional(),
cost: z cost: Cost.optional(),
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({ limit: z.object({
context: z.number(), context: z.number(),
input: z.number().optional(), input: z.number().optional(),
@@ -68,7 +74,24 @@ export namespace ModelsDev {
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
}) })
.optional(), .optional(),
experimental: z.boolean().optional(), experimental: z
.object({
modes: z
.record(
z.string(),
z.object({
cost: Cost.optional(),
provider: z
.object({
body: z.record(z.string(), JsonValue).optional(),
headers: z.record(z.string(), z.string()).optional(),
})
.optional(),
}),
)
.optional(),
})
.optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
}) })
+42 -19
View File
@@ -926,6 +926,28 @@ export namespace Provider {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
const result: Model["cost"] = {
input: c?.input ?? 0,
output: c?.output ?? 0,
cache: {
read: c?.cache_read ?? 0,
write: c?.cache_write ?? 0,
},
}
if (c?.context_over_200k) {
result.experimentalOver200K = {
cache: {
read: c.context_over_200k.cache_read ?? 0,
write: c.context_over_200k.cache_write ?? 0,
},
input: c.context_over_200k.input,
output: c.context_over_200k.output,
}
}
return result
}
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const m: Model = { const m: Model = {
id: ModelID.make(model.id), id: ModelID.make(model.id),
@@ -940,24 +962,7 @@ export namespace Provider {
status: model.status ?? "active", status: model.status ?? "active",
headers: {}, headers: {},
options: {}, options: {},
cost: { cost: cost(model.cost),
input: model.cost?.input ?? 0,
output: model.cost?.output ?? 0,
cache: {
read: model.cost?.cache_read ?? 0,
write: model.cost?.cache_write ?? 0,
},
experimentalOver200K: model.cost?.context_over_200k
? {
cache: {
read: model.cost.context_over_200k.cache_read ?? 0,
write: model.cost.context_over_200k.cache_write ?? 0,
},
input: model.cost.context_over_200k.input,
output: model.cost.context_over_200k.output,
}
: undefined,
},
limit: { limit: {
context: model.limit.context, context: model.limit.context,
input: model.limit.input, input: model.limit.input,
@@ -994,13 +999,31 @@ export namespace Provider {
} }
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
const models: Record<string, Model> = {}
for (const [key, model] of Object.entries(provider.models)) {
models[key] = fromModelsDevModel(provider, model)
for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) {
const id = `${model.id}-${mode}`
const m = fromModelsDevModel(provider, model)
m.id = ModelID.make(id)
m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`
if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost))
// convert body params to camelCase for ai sdk compatibility
if (opts.provider?.body)
m.options = Object.fromEntries(
Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]),
)
if (opts.provider?.headers) m.headers = opts.provider.headers
models[id] = m
}
}
return { return {
id: ProviderID.make(provider.id), id: ProviderID.make(provider.id),
source: "custom", source: "custom",
name: provider.name, name: provider.name,
env: provider.env ?? [], env: provider.env ?? [],
options: {}, options: {},
models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)), models,
} }
} }
-4
View File
@@ -371,10 +371,6 @@ export namespace Pty {
return runPromise((svc) => svc.get(id)) return runPromise((svc) => svc.get(id))
} }
export async function resize(id: PtyID, cols: number, rows: number) {
return runPromise((svc) => svc.resize(id, cols, rows))
}
export async function write(id: PtyID, data: string) { export async function write(id: PtyID, data: string) {
return runPromise((svc) => svc.write(id, data)) return runPromise((svc) => svc.write(id, data))
} }
+28 -13
View File
@@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceRoutes } from "./instance" import { InstanceRoutes } from "./instance"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -26,6 +29,16 @@ function local(method: string, path: string) {
return false return false
} }
async function getSessionWorkspace(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null
const session = await Session.get(SessionID.make(id)).catch(() => undefined)
return session?.workspaceID
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const routes = lazy(() => InstanceRoutes(upgrade)) const routes = lazy(() => InstanceRoutes(upgrade))
@@ -42,13 +55,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
) )
const url = new URL(c.req.url) const url = new URL(c.req.url)
const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace")
// TODO: If session is being routed, force it to lookup the const sessionWorkspaceID = await getSessionWorkspace(url)
// project/workspace const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
// If no workspace is provided we use the "project" workspace // If no workspace is provided we use the project
if (!workspaceParam) { if (!workspaceID) {
return Instance.provide({ return Instance.provide({
directory, directory,
init: InstanceBootstrap, init: InstanceBootstrap,
@@ -58,8 +70,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}) })
} }
const workspaceID = WorkspaceID.make(workspaceParam) const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
const workspace = await Workspace.get(workspaceID)
if (!workspace) { if (!workspace) {
return new Response(`Workspace not found: ${workspaceID}`, { return new Response(`Workspace not found: ${workspaceID}`, {
status: 500, status: 500,
@@ -73,12 +84,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const target = await adaptor.target(workspace) const target = await adaptor.target(workspace)
if (target.type === "local") { if (target.type === "local") {
return Instance.provide({ return WorkspaceContext.provide({
directory: target.directory, workspaceID: WorkspaceID.make(workspaceID),
init: InstanceBootstrap, fn: () =>
async fn() { Instance.provide({
return routes().fetch(c.req.raw, c.env) directory: target.directory,
}, init: InstanceBootstrap,
async fn() {
return routes().fetch(c.req.raw, c.env)
},
}),
}) })
} }
@@ -105,6 +105,8 @@ export const GlobalRoutes = lazy(() =>
z z
.object({ .object({
directory: z.string(), directory: z.string(),
project: z.string().optional(),
workspace: z.string().optional(),
payload: BusEvent.payloads(), payload: BusEvent.payloads(),
}) })
.meta({ .meta({
+25 -9
View File
@@ -6,13 +6,16 @@ import z from "zod"
import { Session } from "../../session" import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "../../session/prompt" import { SessionPrompt } from "../../session/prompt"
import { SessionRunState } from "@/session/run-state"
import { SessionCompaction } from "../../session/compaction" import { SessionCompaction } from "../../session/compaction"
import { SessionRevert } from "../../session/revert" import { SessionRevert } from "../../session/revert"
import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo" import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Command } from "../../command"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
@@ -121,7 +124,6 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
log.info("SEARCH", { url: c.req.url })
const session = await Session.get(sessionID) const session = await Session.get(sessionID)
return c.json(session) return c.json(session)
}, },
@@ -205,10 +207,10 @@ export const SessionRoutes = lazy(() =>
}, },
}, },
}), }),
validator("json", Session.create.schema.optional()), validator("json", Session.create.schema),
async (c) => { async (c) => {
const body = c.req.valid("json") ?? {} const body = c.req.valid("json") ?? {}
const session = await Session.create(body) const session = await SessionShare.create(body)
return c.json(session) return c.json(session)
}, },
) )
@@ -292,6 +294,7 @@ export const SessionRoutes = lazy(() =>
return c.json(session) return c.json(session)
}, },
) )
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
.post( .post(
"/:sessionID/init", "/:sessionID/init",
describeRoute({ describeRoute({
@@ -317,11 +320,24 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
validator("json", Session.initialize.schema.omit({ sessionID: true })), validator(
"json",
z.object({
modelID: ModelID.zod,
providerID: ProviderID.zod,
messageID: MessageID.zod,
}),
),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
await Session.initialize({ ...body, sessionID }) await SessionPrompt.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
})
return c.json(true) return c.json(true)
}, },
) )
@@ -411,7 +427,7 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
await Session.share(sessionID) await SessionShare.share(sessionID)
const session = await Session.get(sessionID) const session = await Session.get(sessionID)
return c.json(session) return c.json(session)
}, },
@@ -476,12 +492,12 @@ export const SessionRoutes = lazy(() =>
validator( validator(
"param", "param",
z.object({ z.object({
sessionID: Session.unshare.schema, sessionID: SessionID.zod,
}), }),
), ),
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
await Session.unshare(sessionID) await SessionShare.unshare(sessionID)
const session = await Session.get(sessionID) const session = await Session.get(sessionID)
return c.json(session) return c.json(session)
}, },
@@ -699,7 +715,7 @@ export const SessionRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
await SessionPrompt.assertNotBusy(params.sessionID) await SessionRunState.assertNotBusy(params.sessionID)
await Session.removeMessage({ await Session.removeMessage({
sessionID: params.sessionID, sessionID: params.sessionID,
messageID: params.messageID, messageID: params.messageID,
+9 -22
View File
@@ -377,17 +377,15 @@ When constructing the summary, try to stick to this template:
}), }),
) )
export const defaultLayer = Layer.unwrap( export const defaultLayer = Layer.suspend(() =>
Effect.sync(() => layer.pipe(
layer.pipe( Layer.provide(Provider.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Bus.layer),
Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer),
Layer.provide(Config.defaultLayer),
),
), ),
) )
@@ -401,17 +399,6 @@ When constructing the summary, try to stick to this template:
return runPromise((svc) => svc.prune(input)) return runPromise((svc) => svc.prune(input))
} }
export const process = fn(
z.object({
parentID: MessageID.zod,
messages: z.custom<MessageV2.WithParts[]>(),
sessionID: SessionID.zod,
auto: z.boolean(),
overflow: z.boolean().optional(),
}),
(input) => runPromise((svc) => svc.process(input)),
)
export const create = fn( export const create = fn(
z.object({ z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
+34 -84
View File
@@ -5,14 +5,13 @@ import { Bus } from "@/bus"
import { Decimal } from "decimal.js" import { Decimal } from "decimal.js"
import z from "zod" import z from "zod"
import { type ProviderMetadata } from "ai" import { type ProviderMetadata } from "ai"
import { Config } from "../config/config"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Installation } from "../installation" import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import { SyncEvent } from "../sync" import { SyncEvent } from "../sync"
import type { SQL } from "../storage/db" import type { SQL } from "../storage/db"
import { SessionTable } from "./session.sql" import { PartTable, SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { Log } from "../util/log" import { Log } from "../util/log"
@@ -20,20 +19,17 @@ import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Command } from "../command"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema" import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema" import { WorkspaceID } from "../control-plane/schema"
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Global } from "@/global" import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider" import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, Scope, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
export namespace Session { export namespace Session {
@@ -322,8 +318,6 @@ export namespace Session {
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info> readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
readonly touch: (sessionID: SessionID) => Effect.Effect<void> readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly get: (id: SessionID) => Effect.Effect<Info> readonly get: (id: SessionID) => Effect.Effect<Info>
readonly share: (id: SessionID) => Effect.Effect<{ url: string }>
readonly unshare: (id: SessionID) => Effect.Effect<void>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void> readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void> readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void> readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
@@ -345,6 +339,11 @@ export namespace Session {
messageID: MessageID messageID: MessageID
partID: PartID partID: PartID
}) => Effect.Effect<PartID> }) => Effect.Effect<PartID>
readonly getPart: (input: {
sessionID: SessionID
messageID: MessageID
partID: PartID
}) => Effect.Effect<MessageV2.Part | undefined>
readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T> readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
readonly updatePartDelta: (input: { readonly updatePartDelta: (input: {
sessionID: SessionID sessionID: SessionID
@@ -353,12 +352,6 @@ export namespace Session {
field: string field: string
delta: string delta: string
}) => Effect.Effect<void> }) => Effect.Effect<void>
readonly initialize: (input: {
sessionID: SessionID
modelID: ModelID
providerID: ProviderID
messageID: MessageID
}) => Effect.Effect<void>
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
@@ -368,12 +361,10 @@ export namespace Session {
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) => const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn)) Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service | Config.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const config = yield* Config.Service
const scope = yield* Scope.Scope
const createNext = Effect.fn("Session.createNext")(function* (input: { const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID id?: SessionID
@@ -403,11 +394,6 @@ export namespace Session {
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
const cfg = yield* config.get()
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
}
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be // This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now // manually publishing this event; it is a sync event now
@@ -426,25 +412,6 @@ export namespace Session {
return fromRow(row) return fromRow(row)
}) })
const share = Effect.fn("Session.share")(function* (id: SessionID) {
const cfg = yield* config.get()
if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* Effect.promise(async () => {
const { ShareNext } = await import("@/share/share-next")
return ShareNext.create(id)
})
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } }))
return result
})
const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) {
yield* Effect.promise(async () => {
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
})
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } }))
})
const children = Effect.fn("Session.children")(function* (parentID: SessionID) { const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const rows = yield* db((d) => const rows = yield* db((d) =>
@@ -464,7 +431,6 @@ export namespace Session {
for (const child of kids) { for (const child of kids) {
yield* remove(child.id) yield* remove(child.id)
} }
yield* unshare(sessionID).pipe(Effect.ignore)
yield* Effect.sync(() => { yield* Effect.sync(() => {
SyncEvent.run(Event.Deleted, { sessionID, info: session }) SyncEvent.run(Event.Deleted, { sessionID, info: session })
SyncEvent.remove(sessionID) SyncEvent.remove(sessionID)
@@ -492,6 +458,29 @@ export namespace Session {
return part return part
}).pipe(Effect.withSpan("Session.updatePart")) }).pipe(Effect.withSpan("Session.updatePart"))
const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) {
const row = Database.use((db) =>
db
.select()
.from(PartTable)
.where(
and(
eq(PartTable.session_id, input.sessionID),
eq(PartTable.message_id, input.messageID),
eq(PartTable.id, input.partID),
),
)
.get(),
)
if (!row) return
return {
...row.data,
id: row.id,
sessionID: row.session_id,
messageID: row.message_id,
} as MessageV2.Part
})
const create = Effect.fn("Session.create")(function* (input?: { const create = Effect.fn("Session.create")(function* (input?: {
parentID?: SessionID parentID?: SessionID
title?: string title?: string
@@ -588,7 +577,7 @@ export namespace Session {
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe( return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]), Effect.orElseSucceed((): Snapshot.FileDiff[] => []),
) )
}) })
@@ -637,30 +626,11 @@ export namespace Session {
yield* bus.publish(MessageV2.Event.PartDelta, input) yield* bus.publish(MessageV2.Event.PartDelta, input)
}) })
const initialize = Effect.fn("Session.initialize")(function* (input: {
sessionID: SessionID
modelID: ModelID
providerID: ProviderID
messageID: MessageID
}) {
yield* Effect.promise(() =>
SessionPrompt.command({
sessionID: input.sessionID,
messageID: input.messageID,
model: input.providerID + "/" + input.modelID,
command: Command.Default.INIT,
arguments: "",
}),
)
})
return Service.of({ return Service.of({
create, create,
fork, fork,
touch, touch,
get, get,
share,
unshare,
setTitle, setTitle,
setArchived, setArchived,
setPermission, setPermission,
@@ -675,13 +645,13 @@ export namespace Session {
removeMessage, removeMessage,
removePart, removePart,
updatePart, updatePart,
getPart,
updatePartDelta, updatePartDelta,
initialize,
}) })
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -701,10 +671,7 @@ export namespace Session {
runPromise((svc) => svc.fork(input)), runPromise((svc) => svc.fork(input)),
) )
export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id)))
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id))) export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id)))
export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id)))
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) => export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
runPromise((svc) => svc.setTitle(input)), runPromise((svc) => svc.setTitle(input)),
@@ -714,24 +681,12 @@ export namespace Session {
runPromise((svc) => svc.setArchived(input)), runPromise((svc) => svc.setArchived(input)),
) )
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
runPromise((svc) => svc.setPermission(input)),
)
export const setRevert = fn( export const setRevert = fn(
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }), z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
(input) => (input) =>
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })), runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
) )
export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id)))
export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) =>
runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })),
)
export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id)))
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) => export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
runPromise((svc) => svc.messages(input)), runPromise((svc) => svc.messages(input)),
) )
@@ -879,9 +834,4 @@ export namespace Session {
}), }),
(input) => runPromise((svc) => svc.updatePartDelta(input)), (input) => runPromise((svc) => svc.updatePartDelta(input)),
) )
export const initialize = fn(
z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }),
(input) => runPromise((svc) => svc.initialize(input)),
)
} }
+6 -2
View File
@@ -234,7 +234,11 @@ export namespace LLM {
// from the workflow service are executed via opencode's tool system // from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket. // and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) { if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n") workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
@@ -301,7 +305,7 @@ export namespace LLM {
ruleset: [], ruleset: [],
}) })
for (const name of uniqueNames) approvedToolsForSession.add(name) for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...workflowModel.sessionPreapprovedTools, ...uniqueNames] workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
return { approved: true } return { approved: true }
} catch { } catch {
return { approved: false } return { approved: false }
+26 -10
View File
@@ -751,16 +751,32 @@ export namespace MessageV2 {
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
}) })
} }
if (part.state.status === "error") if (part.state.status === "error") {
assistantMessage.parts.push({ const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
type: ("tool-" + part.tool) as `tool-${string}`, if (typeof output === "string") {
state: "output-error", assistantMessage.parts.push({
toolCallId: part.callID, type: ("tool-" + part.tool) as `tool-${string}`,
input: part.state.input, state: "output-available",
errorText: part.state.error, toolCallId: part.callID,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), input: part.state.input,
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), output,
}) ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
} else {
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
}
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running") if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({ assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`, type: ("tool-" + part.tool) as `tool-${string}`,
+156 -61
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, ServiceMap } from "effect" import { Cause, Deferred, Effect, Layer, ServiceMap } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Bus } from "@/bus" import { Bus } from "@/bus"
@@ -18,6 +18,8 @@ import { SessionStatus } from "./status"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { Question } from "@/question" import { Question } from "@/question"
import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
export namespace SessionProcessor { export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3 const DOOM_LOOP_THRESHOLD = 3
@@ -29,7 +31,19 @@ export namespace SessionProcessor {
export interface Handle { export interface Handle {
readonly message: MessageV2.Assistant readonly message: MessageV2.Assistant
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined readonly updateToolCall: (
toolCallID: string,
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
) => Effect.Effect<MessageV2.ToolPart | undefined>
readonly completeToolCall: (
toolCallID: string,
output: {
title: string
metadata: Record<string, any>
output: string
attachments?: MessageV2.FilePart[]
},
) => Effect.Effect<void>
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result> readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
} }
@@ -43,8 +57,15 @@ export namespace SessionProcessor {
readonly create: (input: Input) => Effect.Effect<Handle> readonly create: (input: Input) => Effect.Effect<Handle>
} }
type ToolCall = {
partID: MessageV2.ToolPart["id"]
messageID: MessageV2.ToolPart["messageID"]
sessionID: MessageV2.ToolPart["sessionID"]
done: Deferred.Deferred<void>
}
interface ProcessorContext extends Input { interface ProcessorContext extends Input {
toolcalls: Record<string, MessageV2.ToolPart> toolcalls: Record<string, ToolCall>
shouldBreak: boolean shouldBreak: boolean
snapshot: string | undefined snapshot: string | undefined
blocked: boolean blocked: boolean
@@ -107,6 +128,88 @@ export namespace SessionProcessor {
aborted, aborted,
}) })
const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
const done = ctx.toolcalls[toolCallID]?.done
delete ctx.toolcalls[toolCallID]
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
})
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
const call = ctx.toolcalls[toolCallID]
if (!call) return
const part = yield* session.getPart({
partID: call.partID,
messageID: call.messageID,
sessionID: call.sessionID,
})
if (!part || part.type !== "tool") {
delete ctx.toolcalls[toolCallID]
return
}
return { call, part }
})
const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
toolCallID: string,
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
) {
const match = yield* readToolCall(toolCallID)
if (!match) return
const part = yield* session.updatePart(update(match.part))
ctx.toolcalls[toolCallID] = {
...match.call,
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
}
return part
})
const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* (
toolCallID: string,
output: {
title: string
metadata: Record<string, any>
output: string
attachments?: MessageV2.FilePart[]
},
) {
const match = yield* readToolCall(toolCallID)
if (!match || match.part.state.status !== "running") return
yield* session.updatePart({
...match.part,
state: {
status: "completed",
input: match.part.state.input,
output: output.output,
metadata: output.metadata,
title: output.title,
time: { start: match.part.state.time.start, end: Date.now() },
attachments: output.attachments,
},
})
yield* settleToolCall(toolCallID)
})
const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {
const match = yield* readToolCall(toolCallID)
if (!match || match.part.state.status !== "running") return false
yield* session.updatePart({
...match.part,
state: {
status: "error",
input: match.part.state.input,
error: errorMessage(error),
time: { start: match.part.state.time.start, end: Date.now() },
},
})
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
ctx.blocked = ctx.shouldBreak
}
yield* settleToolCall(toolCallID)
return true
})
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) { const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
switch (value.type) { switch (value.type) {
case "start": case "start":
@@ -153,8 +256,8 @@ export namespace SessionProcessor {
if (ctx.assistantMessage.summary) { if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
} }
ctx.toolcalls[value.id] = yield* session.updatePart({ const part = yield* session.updatePart({
id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(), id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
messageID: ctx.assistantMessage.id, messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID, sessionID: ctx.assistantMessage.sessionID,
type: "tool", type: "tool",
@@ -163,6 +266,12 @@ export namespace SessionProcessor {
state: { status: "pending", input: {}, raw: "" }, state: { status: "pending", input: {}, raw: "" },
metadata: value.providerExecuted ? { providerExecuted: true } : undefined, metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies MessageV2.ToolPart) } satisfies MessageV2.ToolPart)
ctx.toolcalls[value.id] = {
done: yield* Deferred.make<void>(),
partID: part.id,
messageID: part.messageID,
sessionID: part.sessionID,
}
return return
case "tool-input-delta": case "tool-input-delta":
@@ -175,16 +284,19 @@ export namespace SessionProcessor {
if (ctx.assistantMessage.summary) { if (ctx.assistantMessage.summary) {
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
} }
const match = ctx.toolcalls[value.toolCallId] yield* updateToolCall(value.toolCallId, (match) => ({
if (!match) return
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
...match, ...match,
tool: value.toolName, tool: value.toolName,
state: { status: "running", input: value.input, time: { start: Date.now() } }, state: {
...match.state,
status: "running",
input: value.input,
time: { start: Date.now() },
},
metadata: match.metadata?.providerExecuted metadata: match.metadata?.providerExecuted
? { ...value.providerMetadata, providerExecuted: true } ? { ...value.providerMetadata, providerExecuted: true }
: value.providerMetadata, : value.providerMetadata,
} satisfies MessageV2.ToolPart) }))
const parts = MessageV2.parts(ctx.assistantMessage.id) const parts = MessageV2.parts(ctx.assistantMessage.id)
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
@@ -215,40 +327,12 @@ export namespace SessionProcessor {
} }
case "tool-result": { case "tool-result": {
const match = ctx.toolcalls[value.toolCallId] yield* completeToolCall(value.toolCallId, value.output)
if (!match || match.state.status !== "running") return
yield* session.updatePart({
...match,
state: {
status: "completed",
input: value.input ?? match.state.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: { start: match.state.time.start, end: Date.now() },
attachments: value.output.attachments,
},
})
delete ctx.toolcalls[value.toolCallId]
return return
} }
case "tool-error": { case "tool-error": {
const match = ctx.toolcalls[value.toolCallId] yield* failToolCall(value.toolCallId, value.error)
if (!match || match.state.status !== "running") return
yield* session.updatePart({
...match,
state: {
status: "error",
input: value.input ?? match.state.input,
error: value.error instanceof Error ? value.error.message : String(value.error),
time: { start: match.state.time.start, end: Date.now() },
},
})
if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) {
ctx.blocked = ctx.shouldBreak
}
delete ctx.toolcalls[value.toolCallId]
return return
} }
@@ -351,7 +435,10 @@ export namespace SessionProcessor {
}, },
{ text: ctx.currentText.text }, { text: ctx.currentText.text },
)).text )).text
ctx.currentText.time = { start: Date.now(), end: Date.now() } {
const end = Date.now()
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
}
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
yield* session.updatePart(ctx.currentText) yield* session.updatePart(ctx.currentText)
ctx.currentText = undefined ctx.currentText = undefined
@@ -398,19 +485,30 @@ export namespace SessionProcessor {
} }
ctx.reasoningMap = {} ctx.reasoningMap = {}
const parts = MessageV2.parts(ctx.assistantMessage.id) yield* Effect.forEach(
for (const part of parts) { Object.values(ctx.toolcalls),
if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue (call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore),
{ concurrency: "unbounded" },
)
for (const toolCallID of Object.keys(ctx.toolcalls)) {
const match = yield* readToolCall(toolCallID)
if (!match) continue
const part = match.part
const end = Date.now()
const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
yield* session.updatePart({ yield* session.updatePart({
...part, ...part,
state: { state: {
...part.state, ...part.state,
status: "error", status: "error",
error: "Tool execution aborted", error: "Tool execution aborted",
time: { start: Date.now(), end: Date.now() }, metadata: { ...metadata, interrupted: true },
time: { start: "time" in part.state ? part.state.time.start : end, end },
}, },
}) })
} }
ctx.toolcalls = {}
ctx.assistantMessage.time.completed = Date.now() ctx.assistantMessage.time.completed = Date.now()
yield* session.updateMessage(ctx.assistantMessage) yield* session.updateMessage(ctx.assistantMessage)
}) })
@@ -486,9 +584,8 @@ export namespace SessionProcessor {
get message() { get message() {
return ctx.assistantMessage return ctx.assistantMessage
}, },
partFromToolCall(toolCallID: string) { updateToolCall,
return ctx.toolcalls[toolCallID] completeToolCall,
},
process, process,
} satisfies Handle } satisfies Handle
}) })
@@ -497,19 +594,17 @@ export namespace SessionProcessor {
}), }),
) )
export const defaultLayer = Layer.unwrap( export const defaultLayer = Layer.suspend(() =>
Effect.sync(() => layer.pipe(
layer.pipe( Layer.provide(Session.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer),
Layer.provide(Snapshot.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(LLM.defaultLayer),
Layer.provide(LLM.defaultLayer), Layer.provide(Permission.defaultLayer),
Layer.provide(Permission.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(SessionStatus.defaultLayer),
Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))), Layer.provide(Bus.layer),
Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer),
Layer.provide(Config.defaultLayer),
),
), ),
) )
} }
+65 -98
View File
@@ -20,7 +20,6 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt" import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "../tool/registry" import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
@@ -48,6 +47,8 @@ import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task" import { TaskTool } from "@/tool/task"
import { Config } from "@/config/config"
import { SessionRunState } from "./run-state"
// @ts-ignore // @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@@ -66,7 +67,6 @@ export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" }) const log = Log.create({ service: "session.prompt" })
export interface Interface { export interface Interface {
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError>
readonly cancel: (sessionID: SessionID) => Effect.Effect<void> readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
@@ -89,6 +89,7 @@ export namespace SessionPrompt {
const compaction = yield* SessionCompaction.Service const compaction = yield* SessionCompaction.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const commands = yield* Command.Service const commands = yield* Command.Service
const config = yield* Config.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
const fsys = yield* AppFileSystem.Service const fsys = yield* AppFileSystem.Service
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
@@ -99,55 +100,12 @@ export namespace SessionPrompt {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const instruction = yield* Instruction.Service const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service
const state = yield* InstanceState.make( const revert = yield* SessionRevert.Service
Effect.fn("SessionPrompt.state")(function* () {
const runners = new Map<string, Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
yield* Effect.forEach(runners.values(), (r) => r.cancel, { concurrency: "unbounded", discard: true })
runners.clear()
}),
)
return { runners }
}),
)
const getRunner = (runners: Map<string, Runner<MessageV2.WithParts>>, sessionID: SessionID) => {
const existing = runners.get(sessionID)
if (existing) return existing
const runner = Runner.make<MessageV2.WithParts>(scope, {
onIdle: Effect.gen(function* () {
runners.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt: lastAssistant(sessionID),
busy: () => {
throw new Session.BusyError(sessionID)
},
})
runners.set(sessionID, runner)
return runner
}
const assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError> = Effect.fn(
"SessionPrompt.assertNotBusy",
)(function* (sessionID: SessionID) {
const s = yield* InstanceState.get(state)
const runner = s.runners.get(sessionID)
if (runner?.busy) throw new Session.BusyError(sessionID)
})
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
log.info("cancel", { sessionID }) log.info("cancel", { sessionID })
const s = yield* InstanceState.get(state) yield* state.cancel(sessionID)
const runner = s.runners.get(sessionID)
if (!runner || !runner.busy) {
yield* status.set(sessionID, { type: "idle" })
return
}
yield* runner.cancel
}) })
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
@@ -184,6 +142,17 @@ export namespace SessionPrompt {
return parts return parts
}) })
let prompt!: Interface["prompt"]
const taskTool = () =>
TaskTool.build({
agent: agents,
config,
cancel: SessionPrompt.cancel,
resolvePromptParts: SessionPrompt.resolvePromptParts,
prompt: SessionPrompt.prompt,
})
const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: { const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
session: Session.Info session: Session.Info
history: MessageV2.WithParts[] history: MessageV2.WithParts[]
@@ -388,7 +357,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
model: Provider.Model model: Provider.Model
session: Session.Info session: Session.Info
tools?: Record<string, boolean> tools?: Record<string, boolean>
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall"> processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
bypassAgentCheck: boolean bypassAgentCheck: boolean
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
}) { }) {
@@ -405,10 +374,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
messages: input.messages, messages: input.messages,
metadata: (val) => metadata: (val) =>
Effect.runPromise( Effect.runPromise(
Effect.gen(function* () { input.processor.updateToolCall(options.toolCallId, (match) => {
const match = input.processor.partFromToolCall(options.toolCallId) if (!["running", "pending"].includes(match.state.status)) return match
if (!match || !["running", "pending"].includes(match.state.status)) return return {
yield* sessions.updatePart({
...match, ...match,
state: { state: {
title: val.title, title: val.title,
@@ -417,7 +385,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
input: args, input: args,
time: { start: Date.now() }, time: { start: Date.now() },
}, },
}) }
}), }),
), ),
ask: (req) => ask: (req) =>
@@ -436,6 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
providerID: input.model.providerID, providerID: input.model.providerID,
agent: input.agent, agent: input.agent,
})) { })) {
const toolDef = item.id === TaskTool.id ? yield* Tool.init(taskTool()) : item
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({ tools[item.id] = tool({
id: item.id as any, id: item.id as any,
@@ -450,7 +419,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args }, { args },
) )
const result = yield* Effect.promise(() => item.execute(args, ctx)) const result = yield* Effect.promise(() => toolDef.execute(args, ctx))
const output = { const output = {
...result, ...result,
attachments: result.attachments?.map((attachment) => ({ attachments: result.attachments?.map((attachment) => ({
@@ -465,6 +434,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
output, output,
) )
if (options.abortSignal?.aborted) {
yield* input.processor.completeToolCall(options.toolCallId, output)
}
return output return output
}), }),
) )
@@ -529,7 +501,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
...(truncated.truncated && { outputPath: truncated.outputPath }), ...(truncated.truncated && { outputPath: truncated.outputPath }),
} }
return { const output = {
title: "", title: "",
metadata, metadata,
output: truncated.content, output: truncated.content,
@@ -541,6 +513,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})), })),
content: result.content, content: result.content,
} }
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}), }),
) )
tools[key] = item tools[key] = item
@@ -559,7 +535,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) { }) {
const { task, model, lastUser, sessionID, session, msgs } = input const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const { task: taskTool } = yield* registry.named()
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
@@ -616,8 +591,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
} }
let error: Error | undefined let error: Error | undefined
const taskDef = yield* Tool.init(taskTool())
const result = yield* Effect.promise((signal) => const result = yield* Effect.promise((signal) =>
taskTool taskDef
.execute(taskArgs, { .execute(taskArgs, {
agent: task.agent, agent: task.agent,
messageID: assistantMessage.id, messageID: assistantMessage.id,
@@ -743,11 +719,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
} satisfies MessageV2.TextPart) } satisfies MessageV2.TextPart)
}) })
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) { const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID) const session = yield* sessions.get(input.sessionID)
if (session.revert) { if (session.revert) {
yield* Effect.promise(() => SessionRevert.cleanup(session)) yield* revert.cleanup(session)
} }
const agent = yield* agents.get(input.agent) const agent = yield* agents.get(input.agent)
if (!agent) { if (!agent) {
@@ -1305,26 +1281,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return { info, parts } return { info, parts }
}, Effect.scoped) }, Effect.scoped)
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")( prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID)
const session = yield* sessions.get(input.sessionID) yield* revert.cleanup(session)
yield* Effect.promise(() => SessionRevert.cleanup(session)) const message = yield* createUserMessage(input)
const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID)
yield* sessions.touch(input.sessionID)
const permissions: Permission.Ruleset = [] const permissions: Permission.Ruleset = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) { for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
} }
if (permissions.length > 0) { if (permissions.length > 0) {
session.permission = permissions session.permission = permissions
yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
} }
if (input.noReply === true) return message if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID }) return yield* loop({ sessionID: input.sessionID })
}, })
)
const lastAssistant = (sessionID: SessionID) => const lastAssistant = (sessionID: SessionID) =>
Effect.promise(async () => { Effect.promise(async () => {
@@ -1507,7 +1481,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Effect.promise(() => SystemPrompt.skills(agent)), Effect.promise(() => SystemPrompt.skills(agent)),
Effect.promise(() => SystemPrompt.environment(model)), Effect.promise(() => SystemPrompt.environment(model)),
instruction.system().pipe(Effect.orDie), instruction.system().pipe(Effect.orDie),
Effect.promise(() => MessageV2.toModelMessages(msgs, model)), MessageV2.toModelMessagesEffect(msgs, model),
]) ])
const system = [...env, ...(skills ? [skills] : []), ...instructions] const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const } const format = lastUser.format ?? { type: "text" as const }
@@ -1568,16 +1542,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn( const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
"SessionPrompt.loop", "SessionPrompt.loop",
)(function* (input: z.infer<typeof LoopInput>) { )(function* (input: z.infer<typeof LoopInput>) {
const s = yield* InstanceState.get(state) return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
const runner = getRunner(s.runners, input.sessionID)
return yield* runner.ensureRunning(runLoop(input.sessionID))
}) })
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")( const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
function* (input: ShellInput) { function* (input: ShellInput) {
const s = yield* InstanceState.get(state) return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input))
const runner = getRunner(s.runners, input.sessionID)
return yield* runner.startShell((signal) => shellImpl(input, signal))
}, },
) )
@@ -1698,7 +1668,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) })
return Service.of({ return Service.of({
assertNotBusy,
cancel, cancel,
prompt, prompt,
loop, loop,
@@ -1709,10 +1678,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}), }),
) )
const defaultLayer = Layer.unwrap( const defaultLayer = Layer.suspend(() =>
Effect.sync(() => layer
layer.pipe( .pipe(
Layer.provide(SessionStatus.layer), Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionCompaction.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer), Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Command.defaultLayer), Layer.provide(Command.defaultLayer),
@@ -1721,24 +1691,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Layer.provide(LSP.defaultLayer), Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer), Layer.provide(FileTime.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer), Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.layer), Layer.provide(Truncate.defaultLayer),
Layer.provide(Provider.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer), )
), .pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)),
),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function assertNotBusy(sessionID: SessionID) {
return runPromise((svc) => svc.assertNotBusy(SessionID.zod.parse(sessionID)))
}
export const PromptInput = z.object({ export const PromptInput = z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
messageID: MessageID.zod.optional(), messageID: MessageID.zod.optional(),
+13 -12
View File
@@ -9,8 +9,9 @@ import { Log } from "../util/log"
import { Session } from "." import { Session } from "."
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
import { SessionPrompt } from "./prompt" import { SessionRunState } from "./run-state"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
import { SessionStatus } from "./status"
export namespace SessionRevert { export namespace SessionRevert {
const log = Log.create({ service: "session.revert" }) const log = Log.create({ service: "session.revert" })
@@ -38,9 +39,10 @@ export namespace SessionRevert {
const storage = yield* Storage.Service const storage = yield* Storage.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID)) yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID }) const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID) const session = yield* sessions.get(input.sessionID)
@@ -93,7 +95,7 @@ export namespace SessionRevert {
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) { const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input) log.info("unreverting", input)
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID)) yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID) const session = yield* sessions.get(input.sessionID)
if (!session.revert) return session if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!) if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
@@ -148,15 +150,14 @@ export namespace SessionRevert {
}), }),
) )
export const defaultLayer = Layer.unwrap( export const defaultLayer = Layer.suspend(() =>
Effect.sync(() => layer.pipe(
layer.pipe( Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer), Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer), Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionSummary.defaultLayer),
),
), ),
) )
+114
View File
@@ -0,0 +1,114 @@
import { InstanceState } from "@/effect/instance-state"
import { Runner } from "@/effect/runner"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { Session } from "."
import { MessageV2 } from "./message-v2"
import { SessionID } from "./schema"
import { SessionStatus } from "./status"
export namespace SessionRunState {
export interface Interface {
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly ensureRunning: (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) => Effect.Effect<MessageV2.WithParts>
readonly startShell: (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) => Effect.Effect<MessageV2.WithParts>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionRunState") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const status = yield* SessionStatus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionRunState.state")(function* () {
const scope = yield* Scope.Scope
const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
concurrency: "unbounded",
discard: true,
})
runners.clear()
}),
)
return { runners, scope }
}),
)
const runner = Effect.fn("SessionRunState.runner")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing) return existing
const next = Runner.make<MessageV2.WithParts>(data.scope, {
onIdle: Effect.gen(function* () {
data.runners.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt,
busy: () => {
throw new Session.BusyError(sessionID)
},
})
data.runners.set(sessionID, next)
return next
})
const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing?.busy) throw new Session.BusyError(sessionID)
})
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing || !existing.busy) {
yield* status.set(sessionID, { type: "idle" })
return
}
yield* existing.cancel
})
const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
})
const startShell = Effect.fn("SessionRunState.startShell")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
})
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function assertNotBusy(sessionID: SessionID) {
return runPromise((svc) => svc.assertNotBusy(sessionID))
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ export namespace SessionStatus {
}), }),
) )
const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(sessionID: SessionID) { export async function get(sessionID: SessionID) {
+6 -8
View File
@@ -150,14 +150,12 @@ export namespace SessionSummary {
}), }),
) )
export const defaultLayer = Layer.unwrap( export const defaultLayer = Layer.suspend(() =>
Effect.sync(() => layer.pipe(
layer.pipe( Layer.provide(Session.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer),
Layer.provide(Snapshot.defaultLayer), Layer.provide(Storage.defaultLayer),
Layer.provide(Storage.defaultLayer), Layer.provide(Bus.layer),
Layer.provide(Bus.layer),
),
), ),
) )
-4
View File
@@ -85,10 +85,6 @@ export namespace Todo {
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function update(input: { sessionID: SessionID; todos: Info[] }) {
return runPromise((svc) => svc.update(input))
}
export async function get(sessionID: SessionID) { export async function get(sessionID: SessionID) {
return runPromise((svc) => svc.get(sessionID)) return runPromise((svc) => svc.get(sessionID))
} }
+67
View File
@@ -0,0 +1,67 @@
import { makeRuntime } from "@/effect/run-service"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { SyncEvent } from "@/sync"
import { fn } from "@/util/fn"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { ShareNext } from "./share-next"
export namespace SessionShare {
export interface Interface {
readonly create: (input?: Parameters<typeof Session.create>[0]) => Effect.Effect<Session.Info>
readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown>
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionShare") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const session = yield* Session.Service
const shareNext = yield* ShareNext.Service
const scope = yield* Scope.Scope
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
const conf = yield* cfg.get()
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* shareNext.create(sessionID)
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }),
)
return result
})
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
yield* shareNext.remove(sessionID)
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
})
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
const result = yield* session.create(input)
if (result.parentID) return result
const conf = yield* cfg.get()
if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result
yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
return result
})
return Service.of({ create, share, unshare })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(ShareNext.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input)))
export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID)))
export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID)))
}
+5 -1
View File
@@ -159,7 +159,10 @@ export namespace ShareNext {
if (disabled) return cache if (disabled) return cache
const watch = <D extends { type: string }>(def: D, fn: (evt: { properties: any }) => Effect.Effect<void>) => const watch = <D extends { type: string }>(
def: D,
fn: (evt: { properties: any }) => Effect.Effect<void, unknown>,
) =>
bus.subscribe(def as never).pipe( bus.subscribe(def as never).pipe(
Stream.runForEach((evt) => Stream.runForEach((evt) =>
fn(evt).pipe( fn(evt).pipe(
@@ -194,6 +197,7 @@ export namespace ShareNext {
yield* watch(Session.Event.Diff, (evt) => yield* watch(Session.Event.Diff, (evt) =>
sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]),
) )
yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID))
return cache return cache
}), }),
+1 -1
View File
@@ -29,7 +29,7 @@ const log = Log.create({ service: "db" })
export namespace Database { export namespace Database {
export function getChannelPath() { export function getChannelPath() {
if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db") return path.join(Global.Path.data, "opencode.db")
const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`) return path.join(Global.Path.data, `opencode-${safe}.db`)
+12 -9
View File
@@ -11,7 +11,11 @@ import { Git } from "@/git"
export namespace Storage { export namespace Storage {
const log = Log.create({ service: "storage" }) const log = Log.create({ service: "storage" })
type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect<void, AppFileSystem.Error> type Migration = (
dir: string,
fs: AppFileSystem.Interface,
git: Git.Interface,
) => Effect.Effect<void, AppFileSystem.Error>
export const NotFoundError = NamedError.create( export const NotFoundError = NamedError.create(
"NotFoundError", "NotFoundError",
@@ -83,7 +87,7 @@ export namespace Storage {
} }
const MIGRATIONS: Migration[] = [ const MIGRATIONS: Migration[] = [
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) { Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
const project = path.resolve(dir, "../project") const project = path.resolve(dir, "../project")
if (!(yield* fs.isDir(project))) return if (!(yield* fs.isDir(project))) return
const projectDirs = yield* fs.glob("*", { const projectDirs = yield* fs.glob("*", {
@@ -110,11 +114,9 @@ export namespace Storage {
} }
if (!worktree) continue if (!worktree) continue
if (!(yield* fs.isDir(worktree))) continue if (!(yield* fs.isDir(worktree))) continue
const result = yield* Effect.promise(() => const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], {
Git.run(["rev-list", "--max-parents=0", "--all"], { cwd: worktree,
cwd: worktree, })
}),
)
const [id] = result const [id] = result
.text() .text()
.split("\n") .split("\n")
@@ -220,6 +222,7 @@ export namespace Storage {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const locks = yield* RcMap.make({ const locks = yield* RcMap.make({
lookup: () => TxReentrantLock.make(), lookup: () => TxReentrantLock.make(),
idleTimeToLive: 0, idleTimeToLive: 0,
@@ -236,7 +239,7 @@ export namespace Storage {
for (let i = migration; i < MIGRATIONS.length; i++) { for (let i = migration; i < MIGRATIONS.length; i++) {
log.info("running migration", { index: i }) log.info("running migration", { index: i })
const step = MIGRATIONS[i]! const step = MIGRATIONS[i]!
const exit = yield* Effect.exit(step(dir, fs)) const exit = yield* Effect.exit(step(dir, fs, git))
if (Exit.isFailure(exit)) { if (Exit.isFailure(exit)) {
log.error("failed to run migration", { index: i, cause: exit.cause }) log.error("failed to run migration", { index: i, cause: exit.cause })
break break
@@ -327,7 +330,7 @@ export namespace Storage {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+56 -123
View File
@@ -1,132 +1,65 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Tool } from "./tool" import { Tool } from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./codesearch.txt" import DESCRIPTION from "./codesearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = { export const CodeSearchTool = Tool.defineEffect(
BASE_URL: "https://mcp.exa.ai", "codesearch",
ENDPOINTS: { Effect.gen(function* () {
CONTEXT: "/mcp", const http = yield* HttpClient.HttpClient
},
} as const
interface McpCodeRequest { return {
jsonrpc: string description: DESCRIPTION,
id: number parameters: z.object({
method: string query: z
params: { .string()
name: string .describe(
arguments: { "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
query: string ),
tokensNum: number tokensNum: z
} .number()
} .min(1000)
} .max(50000)
.default(5000)
.describe(
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
),
}),
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
}),
)
interface McpCodeResponse { const result = yield* McpExa.call(
jsonrpc: string http,
result: { "get_code_context_exa",
content: Array<{ McpExa.CodeArgs,
type: string {
text: string query: params.query,
}> tokensNum: params.tokensNum || 5000,
} },
} "30 seconds",
)
export const CodeSearchTool = Tool.define("codesearch", { return {
description: DESCRIPTION, output:
parameters: z.object({ result ??
query: z "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
.string() title: `Code search: ${params.query}`,
.describe( metadata: {},
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
),
tokensNum: z
.number()
.min(1000)
.max(50000)
.default(5000)
.describe(
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
const codeRequest: McpCodeRequest = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "get_code_context_exa",
arguments: {
query: params.query,
tokensNum: params.tokensNum || 5000,
},
},
}
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
try {
const headers: Record<string, string> = {
accept: "application/json, text/event-stream",
"content-type": "application/json",
}
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, {
method: "POST",
headers,
body: JSON.stringify(codeRequest),
signal,
})
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Code search error (${response.status}): ${errorText}`)
}
const responseText = await response.text()
// Parse SSE response
const lines = responseText.split("\n")
for (const line of lines) {
if (line.startsWith("data: ")) {
const data: McpCodeResponse = JSON.parse(line.substring(6))
if (data.result && data.result.content && data.result.content.length > 0) {
return {
output: data.result.content[0].text,
title: `Code search: ${params.query}`,
metadata: {},
}
} }
} }).pipe(Effect.runPromise),
}
return {
output:
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
title: `Code search: ${params.query}`,
metadata: {},
}
} catch (error) {
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Code search request timed out")
}
throw error
} }
}, }),
}) )
+68 -74
View File
@@ -1,12 +1,13 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import path from "path" import path from "path"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import DESCRIPTION from "./lsp.txt" import DESCRIPTION from "./lsp.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { Filesystem } from "../util/filesystem" import { AppFileSystem } from "../filesystem"
const operations = [ const operations = [
"goToDefinition", "goToDefinition",
@@ -20,78 +21,71 @@ const operations = [
"outgoingCalls", "outgoingCalls",
] as const ] as const
export const LspTool = Tool.define("lsp", { export const LspTool = Tool.defineEffect(
description: DESCRIPTION, "lsp",
parameters: z.object({ Effect.gen(function* () {
operation: z.enum(operations).describe("The LSP operation to perform"), const lsp = yield* LSP.Service
filePath: z.string().describe("The absolute or relative path to the file"), const fs = yield* AppFileSystem.Service
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: async (args, ctx) => {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
await assertExternalDirectory(ctx, file)
await ctx.ask({
permission: "lsp",
patterns: ["*"],
always: ["*"],
metadata: {},
})
const uri = pathToFileURL(file).href
const position = {
file,
line: args.line - 1,
character: args.character - 1,
}
const relPath = path.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = await Filesystem.exists(file)
if (!exists) {
throw new Error(`File not found: ${file}`)
}
const available = await LSP.hasClients(file)
if (!available) {
throw new Error("No LSP server available for this file type.")
}
await LSP.touchFile(file, true)
const result: unknown[] = await (async () => {
switch (args.operation) {
case "goToDefinition":
return LSP.definition(position)
case "findReferences":
return LSP.references(position)
case "hover":
return LSP.hover(position)
case "documentSymbol":
return LSP.documentSymbol(uri)
case "workspaceSymbol":
return LSP.workspaceSymbol("")
case "goToImplementation":
return LSP.implementation(position)
case "prepareCallHierarchy":
return LSP.prepareCallHierarchy(position)
case "incomingCalls":
return LSP.incomingCalls(position)
case "outgoingCalls":
return LSP.outgoingCalls(position)
}
})()
const output = (() => {
if (result.length === 0) return `No results found for ${args.operation}`
return JSON.stringify(result, null, 2)
})()
return { return {
title, description: DESCRIPTION,
metadata: { result }, parameters: z.object({
output, operation: z.enum(operations).describe("The LSP operation to perform"),
filePath: z.string().describe("The absolute or relative path to the file"),
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
}),
execute: (
args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
ctx: Tool.Context,
) =>
Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file)
yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} }))
const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 }
const relPath = path.relative(Instance.worktree, file)
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = yield* fs.existsSafe(file)
if (!exists) throw new Error(`File not found: ${file}`)
const available = yield* lsp.hasClients(file)
if (!available) throw new Error("No LSP server available for this file type.")
yield* lsp.touchFile(file, true)
const result: unknown[] = yield* (() => {
switch (args.operation) {
case "goToDefinition":
return lsp.definition(position)
case "findReferences":
return lsp.references(position)
case "hover":
return lsp.hover(position)
case "documentSymbol":
return lsp.documentSymbol(uri)
case "workspaceSymbol":
return lsp.workspaceSymbol("")
case "goToImplementation":
return lsp.implementation(position)
case "prepareCallHierarchy":
return lsp.prepareCallHierarchy(position)
case "incomingCalls":
return lsp.incomingCalls(position)
case "outgoingCalls":
return lsp.outgoingCalls(position)
}
})()
return {
title,
metadata: { result },
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
}
}).pipe(Effect.runPromise),
} }
}, }),
}) )
+76
View File
@@ -0,0 +1,76 @@
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
const URL = "https://mcp.exa.ai/mcp"
const McpResult = Schema.Struct({
result: Schema.Struct({
content: Schema.Array(
Schema.Struct({
type: Schema.String,
text: Schema.String,
}),
),
}),
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) {
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* decode(line.substring(6))
if (data.result.content[0]?.text) return data.result.content[0].text
}
return undefined
})
export const SearchArgs = Schema.Struct({
query: Schema.String,
type: Schema.String,
numResults: Schema.Number,
livecrawl: Schema.String,
contextMaxCharacters: Schema.optional(Schema.Number),
})
export const CodeArgs = Schema.Struct({
query: Schema.String,
tokensNum: Schema.Number,
})
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: Schema.Literal(1),
method: Schema.Literal("tools/call"),
params: Schema.Struct({
name: Schema.String,
arguments: args,
}),
})
export const call = <F extends Schema.Struct.Fields>(
http: HttpClient.HttpClient,
tool: string,
args: Schema.Struct<F>,
value: Schema.Struct.Type<F>,
timeout: Duration.Input,
) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.post(URL).pipe(
HttpClientRequest.accept("application/json, text/event-stream"),
HttpClientRequest.schemaBodyJson(McpRequest(args))({
jsonrpc: "2.0" as const,
id: 1 as const,
method: "tools/call" as const,
params: { name: tool, arguments: value },
}),
)
const response = yield* HttpClient.filterStatusOk(http)
.execute(request)
.pipe(
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
)
const body = yield* response.text
return yield* parseSse(body)
})
+62 -113
View File
@@ -1,5 +1,6 @@
import z from "zod" import z from "zod"
import path from "path" import path from "path"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Question } from "../question" import { Question } from "../question"
import { Session } from "../session" import { Session } from "../session"
@@ -9,123 +10,71 @@ import { Instance } from "../project/instance"
import { type SessionID, MessageID, PartID } from "../session/schema" import { type SessionID, MessageID, PartID } from "../session/schema"
import EXIT_DESCRIPTION from "./plan-exit.txt" import EXIT_DESCRIPTION from "./plan-exit.txt"
async function getLastModel(sessionID: SessionID) { function getLastModel(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) { for (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model if (item.info.role === "user" && item.info.model) return item.info.model
} }
return Provider.defaultModel() return undefined
} }
export const PlanExitTool = Tool.define("plan_exit", { export const PlanExitTool = Tool.defineEffect(
description: EXIT_DESCRIPTION, "plan_exit",
parameters: z.object({}), Effect.gen(function* () {
async execute(_params, ctx) { const session = yield* Session.Service
const session = await Session.get(ctx.sessionID) const question = yield* Question.Service
const plan = path.relative(Instance.worktree, Session.plan(session)) const provider = yield* Provider.Service
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
header: "Build Agent",
custom: false,
options: [
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "build",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
synthetic: true,
} satisfies MessageV2.TextPart)
return { return {
title: "Switching to build agent", description: EXIT_DESCRIPTION,
output: "User approved switching to build agent. Wait for further instructions.", parameters: z.object({}),
metadata: {}, execute: (_params: {}, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(info))
const answers = yield* question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
header: "Build Agent",
custom: false,
options: [
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
if (answers[0]?.[0] === "No") yield* new Question.RejectedError()
const model = getLastModel(ctx.sessionID) ?? (yield* provider.defaultModel())
const msg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: { created: Date.now() },
agent: "build",
model,
}
yield* session.updateMessage(msg)
yield* session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: ctx.sessionID,
type: "text",
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to build agent",
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
}
}).pipe(Effect.runPromise),
} }
}, }),
}) )
/*
export const PlanEnterTool = Tool.define("plan_enter", {
description: ENTER_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`,
header: "Plan Mode",
custom: false,
options: [
{ label: "Yes", description: "Switch to plan agent for research and planning" },
{ label: "No", description: "Stay with build agent to continue making changes" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "plan",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: PartID.ascending(),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: "User has requested to enter plan mode. Switch to plan mode and begin planning.",
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to plan agent",
output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`,
metadata: {},
}
},
})
*/
+14 -15
View File
@@ -20,27 +20,26 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
return { return {
description: DESCRIPTION, description: DESCRIPTION,
parameters, parameters,
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) { execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
const answers = await question Effect.gen(function* () {
.ask({ const answers = yield* question.ask({
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
questions: params.questions, questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
}) })
.pipe(Effect.runPromise)
const formatted = params.questions const formatted = params.questions
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`) .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
.join(", ") .join(", ")
return { return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`, title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`, output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: { metadata: {
answers, answers,
}, },
} }
}, }).pipe(Effect.runPromise),
} }
}), }),
) )
+76 -23
View File
@@ -1,21 +1,23 @@
import { PlanExitTool } from "./plan" import { PlanExitTool } from "./plan"
import { Session } from "../session"
import { QuestionTool } from "./question" import { QuestionTool } from "./question"
import { BashTool } from "./bash" import { BashTool } from "./bash"
import { EditTool } from "./edit" import { EditTool } from "./edit"
import { GlobTool } from "./glob" import { GlobTool } from "./glob"
import { GrepTool } from "./grep" import { GrepTool } from "./grep"
import { ReadTool } from "./read" import { ReadTool } from "./read"
import { TaskDescription, TaskTool } from "./task" import { TaskTool } from "./task"
import { TodoWriteTool } from "./todo" import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch" import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write" import { WriteTool } from "./write"
import { InvalidTool } from "./invalid" import { InvalidTool } from "./invalid"
import { SkillDescription, SkillTool } from "./skill" import { SkillTool } from "./skill"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Config } from "../config/config" import { Config } from "../config/config"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod" import z from "zod"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { Provider } from "../provider/provider"
import { ProviderID, type ModelID } from "../provider/schema" import { ProviderID, type ModelID } from "../provider/schema"
import { WebSearchTool } from "./websearch" import { WebSearchTool } from "./websearch"
import { CodeSearchTool } from "./codesearch" import { CodeSearchTool } from "./codesearch"
@@ -28,6 +30,7 @@ import { Glob } from "../util/glob"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
@@ -38,6 +41,9 @@ import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction" import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem" import { AppFileSystem } from "../filesystem"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
import type { TaskMetadata } from "./task"
export namespace ToolRegistry { export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
@@ -73,20 +79,31 @@ export namespace ToolRegistry {
| Question.Service | Question.Service
| Todo.Service | Todo.Service
| Agent.Service | Agent.Service
| Skill.Service
| Session.Service
| Provider.Service
| LSP.Service | LSP.Service
| FileTime.Service | FileTime.Service
| Instruction.Service | Instruction.Service
| AppFileSystem.Service | AppFileSystem.Service
| HttpClient.HttpClient
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const skill = yield* Skill.Service
const task = yield* TaskTool const task: Tool.Info<typeof TaskTool.parameters, TaskMetadata> = yield* TaskTool
const read = yield* ReadTool const read = yield* ReadTool
const question = yield* QuestionTool const question = yield* QuestionTool
const todo = yield* TodoWriteTool const todo = yield* TodoWriteTool
const lsptool = yield* LspTool
const plan = yield* PlanExitTool
const webfetch = yield* WebFetchTool
const websearch = yield* WebSearchTool
const codesearch = yield* CodeSearchTool
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -152,15 +169,15 @@ export namespace ToolRegistry {
edit: Tool.init(EditTool), edit: Tool.init(EditTool),
write: Tool.init(WriteTool), write: Tool.init(WriteTool),
task: Tool.init(task), task: Tool.init(task),
fetch: Tool.init(WebFetchTool), fetch: Tool.init(webfetch),
todo: Tool.init(todo), todo: Tool.init(todo),
search: Tool.init(WebSearchTool), search: Tool.init(websearch),
code: Tool.init(CodeSearchTool), code: Tool.init(codesearch),
skill: Tool.init(SkillTool), skill: Tool.init(SkillTool),
patch: Tool.init(ApplyPatchTool), patch: Tool.init(ApplyPatchTool),
question: Tool.init(question), question: Tool.init(question),
lsp: Tool.init(LspTool), lsp: Tool.init(lsptool),
plan: Tool.init(PlanExitTool), plan: Tool.init(plan),
}) })
return { return {
@@ -199,6 +216,40 @@ export namespace ToolRegistry {
return (yield* all()).map((tool) => tool.id) return (yield* all()).map((tool) => tool.id)
}) })
const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) {
const list = yield* skill.available(agent)
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})
const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) {
const items = (yield* agents.list()).filter((item) => item.mode !== "primary")
const filtered = items.filter(
(item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny",
)
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map(
(item) =>
`- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
)
.join("\n")
return ["Available agent types and the tools they have access to:", description].join("\n")
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => { const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
@@ -227,8 +278,8 @@ export namespace ToolRegistry {
id: tool.id, id: tool.id,
description: [ description: [
output.description, output.description,
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined, tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"), .join("\n"),
@@ -250,19 +301,21 @@ export namespace ToolRegistry {
}), }),
) )
export const defaultLayer = Layer.unwrap( export const defaultLayer = Layer.suspend(() =>
Effect.sync(() => layer.pipe(
layer.pipe( Layer.provide(Config.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Question.defaultLayer),
Layer.provide(Question.defaultLayer), Layer.provide(Todo.defaultLayer),
Layer.provide(Todo.defaultLayer), Layer.provide(Skill.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(LSP.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(FileTime.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Instruction.defaultLayer), Layer.provide(LSP.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FileTime.defaultLayer),
), Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
), ),
) )
-21
View File
@@ -1,4 +1,3 @@
import { Effect } from "effect"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
@@ -98,23 +97,3 @@ export const SkillTool = Tool.define("skill", async () => {
}, },
} }
}) })
export const SkillDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const list = yield* Effect.promise(() => Skill.available(agent))
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})
+166 -155
View File
@@ -5,9 +5,8 @@ import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema" import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Permission } from "@/permission" import type { SessionPrompt } from "../session/prompt"
import { Effect } from "effect" import { Effect } from "effect"
const id = "task" const id = "task"
@@ -25,168 +24,180 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(), command: z.string().describe("The command that triggered this task").optional(),
}) })
export const TaskTool = Tool.defineEffect( type Metadata = {
id, sessionId: SessionID
Effect.gen(function* () { model: {
const agent = yield* Agent.Service modelID: MessageV2.Assistant["modelID"]
const config = yield* Config.Service providerID: MessageV2.Assistant["providerID"]
}
}
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) { export type TaskMetadata = Metadata
const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) { type Runtime = {
yield* Effect.promise(() => agent: Agent.Interface
ctx.ask({ config: Config.Interface
permission: id, cancel: (sessionID: SessionID) => Promise<void>
patterns: [params.subagent_type], resolvePromptParts: (template: string) => Promise<SessionPrompt.PromptInput["parts"]>
always: ["*"], prompt: (input: SessionPrompt.PromptInput) => Promise<MessageV2.WithParts>
metadata: { }
description: params.description,
subagent_type: params.subagent_type,
},
}),
)
}
const next = yield* agent.get(params.subagent_type) const unbound: Tool.DefWithoutID<typeof parameters, Metadata> = {
if (!next) { description: DESCRIPTION,
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) parameters,
} async execute() {
throw new Error("Task tool execution is only available from the prompt runtime")
},
}
const canTask = next.permission.some((rule) => rule.permission === id) const build = (runtime: Runtime) => {
const canTodo = next.permission.some((rule) => rule.permission === "todowrite") const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* runtime.config.get()
const taskID = params.task_id if (!ctx.extra?.bypassAgentCheck) {
const session = taskID yield* Effect.promise(() =>
? yield* Effect.promise(() => { ctx.ask({
const id = SessionID.make(taskID) permission: id,
return Session.get(id).catch(() => undefined) patterns: [params.subagent_type],
}) always: ["*"],
: undefined metadata: {
const nextSession = description: params.description,
session ?? subagent_type: params.subagent_type,
(yield* Effect.promise(() => },
Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
const model = next.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
ctx.metadata({
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
})
const messageID = MessageID.ascending()
function cancel() {
SessionPrompt.cancel(nextSession.id)
}
return yield* Effect.acquireUseRelease(
Effect.sync(() => {
ctx.abort.addEventListener("abort", cancel)
}), }),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
SessionPrompt.prompt({
messageID,
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
}),
)
return {
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
output: [
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
result.parts.findLast((item) => item.type === "text")?.text ?? "",
"</task_result>",
].join("\n"),
}
}),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
) )
}
const next = yield* runtime.agent.get(params.subagent_type)
if (!next) {
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const canTask = next.permission.some((rule) => rule.permission === id)
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
const taskID = params.task_id
const session = taskID
? yield* Effect.promise(() => {
const id = SessionID.make(taskID)
return Session.get(id).catch(() => undefined)
})
: undefined
const nextSession =
session ??
(yield* Effect.promise(() =>
Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
const model = next.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
ctx.metadata({
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
}) })
return { const messageID = MessageID.ascending()
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
}
}),
)
export const TaskDescription: Tool.DynamicDescription = (agent) => function cancel() {
Effect.gen(function* () { return runtime.cancel(nextSession.id)
const items = yield* Effect.promise(() => }
Agent.list().then((items) => items.filter((item) => item.mode !== "primary")),
return yield* Effect.acquireUseRelease(
Effect.sync(() => {
ctx.abort.addEventListener("abort", cancel)
}),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => runtime.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
runtime.prompt({
messageID,
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
}),
)
return {
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
output: [
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
result.parts.findLast((item) => item.type === "text")?.text ?? "",
"</task_result>",
].join("\n"),
}
}),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
) )
const filtered = items.filter((item) => Permission.evaluate(id, item.name, agent.permission).action !== "deny")
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map(
(item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
)
.join("\n")
return ["Available agent types and the tools they have access to:", description].join("\n")
}) })
return Tool.define(id, {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
})
}
export const TaskTool = Object.assign(Effect.succeed(Tool.define(id, unbound)), {
id,
description: DESCRIPTION,
parameters,
build,
})
+149 -156
View File
@@ -1,170 +1,163 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Tool } from "./tool" import { Tool } from "./tool"
import TurndownService from "turndown" import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt" import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
import { iife } from "@/util/iife"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const WebFetchTool = Tool.define("webfetch", { const parameters = z.object({
description: DESCRIPTION, url: z.string().describe("The URL to fetch content from"),
parameters: z.object({ format: z
url: z.string().describe("The URL to fetch content from"), .enum(["text", "markdown", "html"])
format: z .default("markdown")
.enum(["text", "markdown", "html"]) .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
.default("markdown") timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
}),
async execute(params, ctx) {
// Validate URL
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
await ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
})
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
// Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*"
switch (params.format) {
case "markdown":
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
break
case "text":
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
break
case "html":
acceptHeader = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
break
default:
acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
}
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
Accept: acceptHeader,
"Accept-Language": "en-US,en;q=0.9",
}
const response = await iife(async () => {
try {
const initial = await fetch(params.url, { signal, headers })
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
: initial
} finally {
clearTimeout()
}
})
if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`)
}
// Check content length
const contentLength = response.headers.get("content-length")
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const arrayBuffer = await response.arrayBuffer()
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const contentType = response.headers.get("content-type") || ""
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})`
// Check if response is an image
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
if (isImage) {
const base64Content = Buffer.from(arrayBuffer).toString("base64")
return {
title,
output: "Image fetched successfully",
metadata: {},
attachments: [
{
type: "file",
mime,
url: `data:${mime};base64,${base64Content}`,
},
],
}
}
const content = new TextDecoder().decode(arrayBuffer)
// Handle content based on requested format and actual content type
switch (params.format) {
case "markdown":
if (contentType.includes("text/html")) {
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
title,
metadata: {},
}
}
return {
output: content,
title,
metadata: {},
}
case "text":
if (contentType.includes("text/html")) {
const text = await extractTextFromHTML(content)
return {
output: text,
title,
metadata: {},
}
}
return {
output: content,
title,
metadata: {},
}
case "html":
return {
output: content,
title,
metadata: {},
}
default:
return {
output: content,
title,
metadata: {},
}
}
},
}) })
export const WebFetchTool = Tool.defineEffect(
"webfetch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(http)
return {
description: DESCRIPTION,
parameters,
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
throw new Error("URL must start with http:// or https://")
}
yield* Effect.promise(() =>
ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
metadata: {
url: params.url,
format: params.format,
timeout: params.timeout,
},
}),
)
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
// Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*"
switch (params.format) {
case "markdown":
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
break
case "text":
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
break
case "html":
acceptHeader =
"text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
break
default:
acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
}
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
Accept: acceptHeader,
"Accept-Language": "en-US,en;q=0.9",
}
const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
const response = yield* httpOk.execute(request).pipe(
Effect.catchIf(
(err) =>
err.reason._tag === "StatusCodeError" &&
err.reason.response.status === 403 &&
err.reason.response.headers["cf-mitigated"] === "challenge",
() =>
httpOk.execute(
HttpClientRequest.get(params.url).pipe(
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
),
),
),
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
)
// Check content length
const contentLength = response.headers["content-length"]
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const arrayBuffer = yield* response.arrayBuffer
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
throw new Error("Response too large (exceeds 5MB limit)")
}
const contentType = response.headers["content-type"] || ""
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})`
// Check if response is an image
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
if (isImage) {
const base64Content = Buffer.from(arrayBuffer).toString("base64")
return {
title,
output: "Image fetched successfully",
metadata: {},
attachments: [
{
type: "file" as const,
mime,
url: `data:${mime};base64,${base64Content}`,
},
],
}
}
const content = new TextDecoder().decode(arrayBuffer)
// Handle content based on requested format and actual content type
switch (params.format) {
case "markdown":
if (contentType.includes("text/html")) {
const markdown = convertHTMLToMarkdown(content)
return {
output: markdown,
title,
metadata: {},
}
}
return { output: content, title, metadata: {} }
case "text":
if (contentType.includes("text/html")) {
const text = yield* Effect.promise(() => extractTextFromHTML(content))
return { output: text, title, metadata: {} }
}
return { output: content, title, metadata: {} }
case "html":
return { output: content, title, metadata: {} }
default:
return { output: content, title, metadata: {} }
}
}).pipe(Effect.runPromise),
}
}),
)
async function extractTextFromHTML(html: string) { async function extractTextFromHTML(html: string) {
let text = "" let text = ""
let skipContent = false let skipContent = false
+49 -123
View File
@@ -1,15 +1,9 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Tool } from "./tool" import { Tool } from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./websearch.txt" import DESCRIPTION from "./websearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
ENDPOINTS: {
SEARCH: "/mcp",
},
DEFAULT_NUM_RESULTS: 8,
} as const
const Parameters = z.object({ const Parameters = z.object({
query: z.string().describe("Websearch query"), query: z.string().describe("Websearch query"),
@@ -30,121 +24,53 @@ const Parameters = z.object({
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"), .describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}) })
interface McpSearchRequest { export const WebSearchTool = Tool.defineEffect(
jsonrpc: string "websearch",
id: number Effect.gen(function* () {
method: string const http = yield* HttpClient.HttpClient
params: {
name: string
arguments: {
query: string
numResults?: number
livecrawl?: "fallback" | "preferred"
type?: "auto" | "fast" | "deep"
contextMaxCharacters?: number
}
}
}
interface McpSearchResponse { return {
jsonrpc: string get description() {
result: { return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
content: Array<{ },
type: string parameters: Parameters,
text: string execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
}> Effect.gen(function* () {
} yield* Effect.promise(() =>
} ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
}),
)
export const WebSearchTool = Tool.define("websearch", async () => { const result = yield* McpExa.call(
return { http,
get description() { "web_search_exa",
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) McpExa.SearchArgs,
}, {
parameters: Parameters, query: params.query,
async execute(params, ctx) { type: params.type || "auto",
await ctx.ask({ numResults: params.numResults || 8,
permission: "websearch", livecrawl: params.livecrawl || "fallback",
patterns: [params.query], contextMaxCharacters: params.contextMaxCharacters,
always: ["*"], },
metadata: { "25 seconds",
query: params.query, )
numResults: params.numResults,
livecrawl: params.livecrawl,
type: params.type,
contextMaxCharacters: params.contextMaxCharacters,
},
})
const searchRequest: McpSearchRequest = { return {
jsonrpc: "2.0", output: result ?? "No search results found. Please try a different query.",
id: 1, title: `Web search: ${params.query}`,
method: "tools/call", metadata: {},
params: {
name: "web_search_exa",
arguments: {
query: params.query,
type: params.type || "auto",
numResults: params.numResults || API_CONFIG.DEFAULT_NUM_RESULTS,
livecrawl: params.livecrawl || "fallback",
contextMaxCharacters: params.contextMaxCharacters,
},
},
}
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
try {
const headers: Record<string, string> = {
accept: "application/json, text/event-stream",
"content-type": "application/json",
}
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, {
method: "POST",
headers,
body: JSON.stringify(searchRequest),
signal,
})
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Search error (${response.status}): ${errorText}`)
}
const responseText = await response.text()
// Parse SSE response
const lines = responseText.split("\n")
for (const line of lines) {
if (line.startsWith("data: ")) {
const data: McpSearchResponse = JSON.parse(line.substring(6))
if (data.result && data.result.content && data.result.content.length > 0) {
return {
output: data.result.content[0].text,
title: `Web search: ${params.query}`,
metadata: {},
}
}
} }
} }).pipe(Effect.runPromise),
}
return { }),
output: "No search results found. Please try a different query.", )
title: `Web search: ${params.query}`,
metadata: {},
}
} catch (error) {
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Search request timed out")
}
throw error
}
},
}
})
+11 -2
View File
@@ -171,7 +171,7 @@ export namespace Worktree {
export const layer: Layer.Layer< export const layer: Layer.Layer<
Service, Service,
never, never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -179,6 +179,7 @@ export namespace Worktree {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const gitSvc = yield* Git.Service
const project = yield* Project.Service const project = yield* Project.Service
const git = Effect.fnUntraced( const git = Effect.fnUntraced(
@@ -245,6 +246,7 @@ export namespace Worktree {
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const workspaceID = yield* InstanceState.workspaceID
const projectID = ctx.project.id const projectID = ctx.project.id
const extra = startCommand?.trim() const extra = startCommand?.trim()
@@ -254,6 +256,8 @@ export namespace Worktree {
log.error("worktree checkout failed", { directory: info.directory, message }) log.error("worktree checkout failed", { directory: info.directory, message })
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: info.directory, directory: info.directory,
project: ctx.project.id,
workspace: workspaceID,
payload: { type: Event.Failed.type, properties: { message } }, payload: { type: Event.Failed.type, properties: { message } },
}) })
return return
@@ -271,6 +275,8 @@ export namespace Worktree {
log.error("worktree bootstrap failed", { directory: info.directory, message }) log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: info.directory, directory: info.directory,
project: ctx.project.id,
workspace: workspaceID,
payload: { type: Event.Failed.type, properties: { message } }, payload: { type: Event.Failed.type, properties: { message } },
}) })
return false return false
@@ -280,6 +286,8 @@ export namespace Worktree {
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: info.directory, directory: info.directory,
project: ctx.project.id,
workspace: workspaceID,
payload: { payload: {
type: Event.Ready.type, type: Event.Ready.type,
properties: { name: info.name, branch: info.branch }, properties: { name: info.name, branch: info.branch },
@@ -516,7 +524,7 @@ export namespace Worktree {
const worktreePath = entry.path const worktreePath = entry.path
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree)) const base = yield* gitSvc.defaultBranch(Instance.worktree)
if (!base) { if (!base) {
throw new ResetFailedError({ message: "Default branch not found" }) throw new ResetFailedError({ message: "Default branch not found" })
} }
@@ -583,6 +591,7 @@ export namespace Worktree {
) )
const defaultLayer = layer.pipe( const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Project.defaultLayer), Layer.provide(Project.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
@@ -0,0 +1,293 @@
/** @jsxImportSource @opentui/solid */
import { afterEach, describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
const sighup = new Set(process.listeners("SIGHUP"))
afterEach(() => {
for (const fn of process.listeners("SIGHUP")) {
if (!sighup.has(fn)) process.off("SIGHUP", fn)
}
})
function json(data: unknown) {
return new Response(JSON.stringify(data), {
headers: {
"content-type": "application/json",
},
})
}
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function data(workspace?: string | null) {
const tag = workspace ?? "root"
return {
session: {
id: "ses_1",
title: `session-${tag}`,
workspaceID: workspace ?? undefined,
time: {
updated: 1,
},
},
message: {
info: {
id: "msg_1",
sessionID: "ses_1",
role: "assistant",
time: {
created: 1,
completed: 1,
},
},
parts: [
{
id: "part_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: `part-${tag}`,
},
],
},
todo: [
{
id: `todo-${tag}`,
content: `todo-${tag}`,
status: "pending",
priority: "medium",
},
],
diff: [
{
file: `${tag}.ts`,
patch: "",
additions: 0,
deletions: 0,
},
],
}
}
type Hit = {
path: string
workspace?: string
}
function createFetch(log: Hit[]) {
return Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const req = new Request(input, init)
const url = new URL(req.url)
const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined
log.push({
path: url.pathname,
workspace,
})
if (url.pathname === "/config/providers") {
return json({ providers: [], default: {} })
}
if (url.pathname === "/provider") {
return json({ all: [], default: {}, connected: [] })
}
if (url.pathname === "/experimental/console") {
return json({})
}
if (url.pathname === "/agent") {
return json([])
}
if (url.pathname === "/config") {
return json({})
}
if (url.pathname === "/project/current") {
return json({ id: `proj-${workspace ?? "root"}` })
}
if (url.pathname === "/path") {
return json({
state: `/tmp/${workspace ?? "root"}/state`,
config: `/tmp/${workspace ?? "root"}/config`,
worktree: "/tmp/worktree",
directory: `/tmp/${workspace ?? "root"}`,
})
}
if (url.pathname === "/session") {
return json([])
}
if (url.pathname === "/command") {
return json([])
}
if (url.pathname === "/lsp") {
return json([])
}
if (url.pathname === "/mcp") {
return json({})
}
if (url.pathname === "/experimental/resource") {
return json({})
}
if (url.pathname === "/formatter") {
return json([])
}
if (url.pathname === "/session/status") {
return json({})
}
if (url.pathname === "/provider/auth") {
return json({})
}
if (url.pathname === "/vcs") {
return json({ branch: "main" })
}
if (url.pathname === "/experimental/workspace") {
return json([{ id: "ws_a" }, { id: "ws_b" }])
}
if (url.pathname === "/session/ses_1") {
return json(data(workspace).session)
}
if (url.pathname === "/session/ses_1/message") {
return json([data(workspace).message])
}
if (url.pathname === "/session/ses_1/todo") {
return json(data(workspace).todo)
}
if (url.pathname === "/session/ses_1/diff") {
return json(data(workspace).diff)
}
throw new Error(`unexpected request: ${req.method} ${url.pathname}`)
},
{ preconnect: fetch.preconnect.bind(fetch) },
) satisfies typeof fetch
}
async function mount(log: Hit[]) {
let project!: ReturnType<typeof useProject>
let sync!: ReturnType<typeof useSync>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
const app = await testRender(() => (
<SDKProvider
url="http://test"
directory="/tmp/root"
fetch={createFetch(log)}
events={{ subscribe: async () => () => {} }}
>
<ArgsProvider continue={false}>
<ExitProvider>
<ProjectProvider>
<SyncProvider>
<Probe
onReady={(ctx) => {
project = ctx.project
sync = ctx.sync
done()
}}
/>
</SyncProvider>
</ProjectProvider>
</ExitProvider>
</ArgsProvider>
</SDKProvider>
))
await ready
return { app, project, sync }
}
async function waitBoot(log: Hit[], workspace?: string) {
await wait(() => log.some((item) => item.path === "/experimental/workspace"))
if (!workspace) return
await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace))
}
function Probe(props: {
onReady: (ctx: { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
}) {
const project = useProject()
const sync = useSync()
onMount(() => {
props.onReady({ project, sync })
})
return <box />
}
describe("SyncProvider", () => {
test("re-runs bootstrap requests when the active workspace changes", async () => {
const log: Hit[] = []
const { app, project } = await mount(log)
try {
await waitBoot(log)
log.length = 0
project.workspace.set("ws_a")
await waitBoot(log, "ws_a")
expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
} finally {
app.renderer.destroy()
}
})
test("clears full-sync cache when the active workspace changes", async () => {
const log: Hit[] = []
const { app, project, sync } = await mount(log)
try {
await waitBoot(log)
log.length = 0
project.workspace.set("ws_a")
await waitBoot(log, "ws_a")
expect(project.workspace.current()).toBe("ws_a")
log.length = 0
await sync.session.sync("ses_1")
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
log.length = 0
project.workspace.set("ws_b")
await waitBoot(log, "ws_b")
expect(project.workspace.current()).toBe("ws_b")
log.length = 0
await sync.session.sync("ses_1")
await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
} finally {
app.renderer.destroy()
}
})
})
@@ -0,0 +1,175 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
return {
directory: input.directory,
workspace: input.workspace,
payload,
}
}
function vcs(branch: string): Event {
return {
type: "vcs.branch.updated",
properties: {
branch,
},
}
}
function update(version: string): Event {
return {
type: "installation.update-available",
properties: {
version,
},
}
}
function createSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
},
emit(evt: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(evt)
},
}
}
async function mount() {
const source = createSource()
const seen: Event[] = []
let project!: ReturnType<typeof useProject>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
const app = await testRender(() => (
<SDKProvider url="http://test" directory="/tmp/root" events={source.source}>
<ProjectProvider>
<Probe
onReady={(ctx) => {
project = ctx.project
done()
}}
seen={seen}
/>
</ProjectProvider>
</SDKProvider>
))
await ready
return { app, emit: source.emit, project, seen }
}
function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
const project = useProject()
const event = useEvent()
onMount(() => {
event.subscribe((evt) => {
props.seen.push(evt)
})
props.onReady({ project })
})
return <box />
}
describe("useEvent", () => {
test("delivers matching directory events without an active workspace", async () => {
const { app, emit, seen } = await mount()
try {
emit(event(vcs("main"), { directory: "/tmp/root" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("main")])
} finally {
app.renderer.destroy()
}
})
test("ignores non-matching directory events without an active workspace", async () => {
const { app, emit, seen } = await mount()
try {
emit(event(vcs("other"), { directory: "/tmp/other" }))
await Bun.sleep(30)
expect(seen).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test("delivers matching workspace events when a workspace is active", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("ws")])
} finally {
app.renderer.destroy()
}
})
test("ignores non-matching workspace events when a workspace is active", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" }))
await Bun.sleep(30)
expect(seen).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test("delivers truly global events even when a workspace is active", async () => {
const { app, emit, project, seen } = await mount()
try {
project.workspace.set("ws_a")
emit(event(update("1.2.3"), { directory: "global" }))
await wait(() => seen.length === 1)
expect(seen).toEqual([update("1.2.3")])
} finally {
app.renderer.destroy()
}
})
})
+14 -42
View File
@@ -250,7 +250,7 @@ describe("Runner", () => {
Effect.gen(function* () { Effect.gen(function* () {
const s = yield* Scope.Scope const s = yield* Scope.Scope
const runner = Runner.make<string>(s) const runner = Runner.make<string>(s)
const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) const result = yield* runner.startShell(Effect.succeed("shell-done"))
expect(result).toBe("shell-done") expect(result).toBe("shell-done")
expect(runner.busy).toBe(false) expect(runner.busy).toBe(false)
}), }),
@@ -264,7 +264,7 @@ describe("Runner", () => {
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit) const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
yield* runner.cancel yield* runner.cancel
@@ -279,12 +279,10 @@ describe("Runner", () => {
const runner = Runner.make<string>(s) const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>() const gate = yield* Deferred.make<void>()
const sh = yield* runner const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild)
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first")))
.pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
yield* Deferred.succeed(gate, undefined) yield* Deferred.succeed(gate, undefined)
@@ -302,37 +300,26 @@ describe("Runner", () => {
}, },
}) })
const sh = yield* runner const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
.startShell((signal) =>
Effect.promise(
() =>
new Promise<string>((resolve) => {
signal.addEventListener("abort", () => resolve("aborted"), { once: true })
}),
),
)
.pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
yield* runner.cancel yield* runner.cancel
const done = yield* Fiber.await(sh) const done = yield* Fiber.await(sh)
expect(Exit.isSuccess(done)).toBe(true) expect(Exit.isFailure(done)).toBe(true)
}), }),
) )
it.live( it.live(
"cancel interrupts shell that ignores abort signal", "cancel interrupts shell",
Effect.gen(function* () { Effect.gen(function* () {
const s = yield* Scope.Scope const s = yield* Scope.Scope
const runner = Runner.make<string>(s) const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>() const gate = yield* Deferred.make<void>()
const sh = yield* runner const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild)
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ignored")))
.pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
const stop = yield* runner.cancel.pipe(Effect.forkChild) const stop = yield* runner.cancel.pipe(Effect.forkChild)
@@ -356,9 +343,7 @@ describe("Runner", () => {
const runner = Runner.make<string>(s) const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>() const gate = yield* Deferred.make<void>()
const sh = yield* runner const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild)
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result")))
.pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
expect(runner.state._tag).toBe("Shell") expect(runner.state._tag).toBe("Shell")
@@ -384,9 +369,7 @@ describe("Runner", () => {
const calls = yield* Ref.make(0) const calls = yield* Ref.make(0)
const gate = yield* Deferred.make<void>() const gate = yield* Deferred.make<void>()
const sh = yield* runner const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild)
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell")))
.pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
const work = Effect.gen(function* () { const work = Effect.gen(function* () {
@@ -414,16 +397,7 @@ describe("Runner", () => {
const runner = Runner.make<string>(s) const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>() const gate = yield* Deferred.make<void>()
const sh = yield* runner const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
.startShell((signal) =>
Effect.promise(
() =>
new Promise<string>((resolve) => {
signal.addEventListener("abort", () => resolve("aborted"), { once: true })
}),
),
)
.pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
@@ -478,7 +452,7 @@ describe("Runner", () => {
const runner = Runner.make<string>(s, { const runner = Runner.make<string>(s, {
onBusy: Ref.update(count, (n) => n + 1), onBusy: Ref.update(count, (n) => n + 1),
}) })
yield* runner.startShell((_signal) => Effect.succeed("done")) yield* runner.startShell(Effect.succeed("done"))
expect(yield* Ref.get(count)).toBe(1) expect(yield* Ref.get(count)).toBe(1)
}), }),
) )
@@ -509,9 +483,7 @@ describe("Runner", () => {
const runner = Runner.make<string>(s) const runner = Runner.make<string>(s)
const gate = yield* Deferred.make<void>() const gate = yield* Deferred.make<void>()
const fiber = yield* runner const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok")))
.pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
expect(runner.busy).toBe(true) expect(runner.busy).toBe(true)
@@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
@@ -32,6 +33,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
fn: async () => { fn: async () => {
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe( const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(watcherConfigLayer), Layer.provide(watcherConfigLayer),
) )
const rt = ManagedRuntime.make(layer) const rt = ManagedRuntime.make(layer)
-78
View File
@@ -1,8 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test" import { describe, expect, spyOn, test } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises"
import * as Lsp from "../../src/lsp/index" import * as Lsp from "../../src/lsp/index"
import * as launch from "../../src/lsp/launch"
import { LSPServer } from "../../src/lsp/server" import { LSPServer } from "../../src/lsp/server"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -54,80 +52,4 @@ describe("lsp.spawn", () => {
await Instance.disposeAll() await Instance.disposeAll()
} }
}) })
test("spawns builtin Typescript LSP with correct arguments", async () => {
await using tmp = await tmpdir()
// Create dummy tsserver to satisfy Module.resolve
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
await fs.mkdir(tsdk, { recursive: true })
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
() =>
({
stdin: {},
stdout: {},
stderr: {},
on: () => {},
kill: () => {},
}) as any,
)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSPServer.Typescript.spawn(tmp.path)
},
})
expect(spawnSpy).toHaveBeenCalled()
const args = spawnSpy.mock.calls[0][1] as string[]
expect(args).toContain("--tsserver-path")
expect(args).toContain("--tsserver-log-verbosity")
expect(args).toContain("off")
} finally {
spawnSpy.mockRestore()
}
})
test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => {
await using tmp = await tmpdir()
// Create dummy tsserver to satisfy Module.resolve
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
await fs.mkdir(tsdk, { recursive: true })
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
// NO tsconfig.json or jsconfig.json created here
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
() =>
({
stdin: {},
stdout: {},
stderr: {},
on: () => {},
kill: () => {},
}) as any,
)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await LSPServer.Typescript.spawn(tmp.path)
},
})
expect(spawnSpy).toHaveBeenCalled()
const args = spawnSpy.mock.calls[0][1] as string[]
expect(args).toContain("--ignore-node-modules")
} finally {
spawnSpy.mockRestore()
}
})
}) })
@@ -0,0 +1,34 @@
import { test, expect, describe, afterEach } from "bun:test"
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
import { parseRedirectUri } from "../../src/mcp/oauth-provider"
describe("parseRedirectUri", () => {
test("returns defaults when no URI provided", () => {
const result = parseRedirectUri()
expect(result.port).toBe(19876)
expect(result.path).toBe("/mcp/oauth/callback")
})
test("parses port and path from URI", () => {
const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
expect(result.port).toBe(8080)
expect(result.path).toBe("/oauth/callback")
})
test("returns defaults for invalid URI", () => {
const result = parseRedirectUri("not-a-valid-url")
expect(result.port).toBe(19876)
expect(result.path).toBe("/mcp/oauth/callback")
})
})
describe("McpOAuthCallback.ensureRunning", () => {
afterEach(async () => {
await McpOAuthCallback.stop()
})
test("starts server with custom redirectUri port and path", async () => {
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
expect(McpOAuthCallback.isRunning()).toBe(true)
})
})
@@ -1,5 +1,7 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch" import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@@ -30,7 +32,11 @@ describe("memory: abort controller leak", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const tool = await WebFetchTool.init() const tool = await WebFetchTool.pipe(
Effect.flatMap((info) => Effect.promise(() => info.init())),
Effect.provide(FetchHttpClient.layer),
Effect.runPromise,
)
// Warm up // Warm up
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {}) await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
@@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Plugin } from "../../src/plugin/index" import { Plugin } from "../../src/plugin/index"
import { ModelsDev } from "../../src/provider/models"
import { Provider } from "../../src/provider/provider" import { Provider } from "../../src/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema" import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
@@ -1823,6 +1824,73 @@ test("custom model inherits api.url from models.dev provider", async () => {
}) })
}) })
test("mode cost preserves over-200k pricing from base model", () => {
const provider = {
id: "openai",
name: "OpenAI",
env: [],
api: "https://api.openai.com/v1",
models: {
"gpt-5.4": {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
release_date: "2026-03-05",
attachment: true,
reasoning: true,
temperature: false,
tool_call: true,
cost: {
input: 2.5,
output: 15,
cache_read: 0.25,
context_over_200k: {
input: 5,
output: 22.5,
cache_read: 0.5,
},
},
limit: {
context: 1_050_000,
input: 922_000,
output: 128_000,
},
experimental: {
modes: {
fast: {
cost: {
input: 5,
output: 30,
cache_read: 0.5,
},
provider: {
body: {
service_tier: "priority",
},
},
},
},
},
},
},
} as ModelsDev.Provider
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
expect(model.cost.input).toEqual(5)
expect(model.cost.output).toEqual(30)
expect(model.cost.cache.read).toEqual(0.5)
expect(model.cost.cache.write).toEqual(0)
expect(model.options["serviceTier"]).toEqual("priority")
expect(model.cost.experimentalOver200K).toEqual({
input: 5,
output: 22.5,
cache: {
read: 0.5,
write: 0,
},
})
})
test("model variants are generated for reasoning models", async () => { test("model variants are generated for reasoning models", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {

Some files were not shown because too many files have changed in this diff Show More