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
90 changed files with 2872 additions and 2309 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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "version": "1.4.3",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2", "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.2" 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.2/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.2/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.2/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.2/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.2/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.2", "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.2", "version": "1.4.3",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+14 -9
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: () => {},
+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)
+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()
@@ -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)
}, },
@@ -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)
@@ -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>(
+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
} }
+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)
+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,
+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 -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({
+23 -7
View File
@@ -9,11 +9,13 @@ import { SessionPrompt } from "../../session/prompt"
import { SessionRunState } from "@/session/run-state" 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"
@@ -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)
}, },
+9 -11
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),
),
), ),
) )
+4 -70
View File
@@ -5,7 +5,6 @@ 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"
@@ -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>
@@ -358,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") {}
@@ -373,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
@@ -408,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
@@ -431,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) =>
@@ -469,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)
@@ -616,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[] => []),
) )
}) })
@@ -665,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,
@@ -705,12 +647,11 @@ export namespace Session {
updatePart, updatePart,
getPart, 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)
@@ -731,8 +672,6 @@ export namespace Session {
) )
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)),
@@ -895,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)),
)
} }
+11 -13
View File
@@ -594,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),
),
), ),
) )
} }
+45 -31
View File
@@ -47,6 +47,7 @@ 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" import { SessionRunState } from "./run-state"
// @ts-ignore // @ts-ignore
@@ -88,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,6 +101,7 @@ export namespace SessionPrompt {
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* SessionRunState.Service
const revert = yield* SessionRevert.Service
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 })
@@ -139,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[]
@@ -390,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,
@@ -404,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) => ({
@@ -520,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(),
@@ -577,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,
@@ -708,7 +723,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
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) {
@@ -1266,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 () => {
@@ -1665,11 +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(SessionRunState.layer), Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.layer), 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),
@@ -1678,17 +1691,18 @@ 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)
+8 -11
View File
@@ -150,17 +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(SessionRunState.layer), Layer.provide(Session.defaultLayer),
Layer.provide(SessionStatus.layer), Layer.provide(Snapshot.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Storage.defaultLayer),
Layer.provide(Snapshot.defaultLayer), Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer), Layer.provide(SessionSummary.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
),
), ),
) )
+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),
),
), ),
) )
+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 -156
View File
@@ -5,11 +5,9 @@ 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"
import { Log } from "@/util/log"
const id = "task" const id = "task"
@@ -26,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()
}
})
})
@@ -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()
}
})
}) })
@@ -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(() => {})
@@ -1,4 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path" import path from "path"
@@ -25,11 +26,14 @@ import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor" import { SessionProcessor } from "../../src/session/processor"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
import { SessionRunState } from "../../src/session/run-state" import { SessionRunState } from "../../src/session/run-state"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session/status"
import { Skill } from "../../src/skill"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
@@ -166,6 +170,8 @@ function makeHttp() {
const question = Question.layer.pipe(Layer.provideMerge(deps)) const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
@@ -176,6 +182,7 @@ function makeHttp() {
return Layer.mergeAll( return Layer.mergeAll(
TestLLMServer.layer, TestLLMServer.layer,
SessionPrompt.layer.pipe( SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provideMerge(run), Layer.provideMerge(run),
Layer.provideMerge(compact), Layer.provideMerge(compact),
Layer.provideMerge(proc), Layer.provideMerge(proc),
@@ -721,23 +728,31 @@ it.live(
Effect.gen(function* () { Effect.gen(function* () {
const ready = defer<void>() const ready = defer<void>()
const aborted = defer<void>() const aborted = defer<void>()
const registry = yield* ToolRegistry.Service const original = TaskTool.build
const { task } = yield* registry.named() TaskTool.build = ((runtime: Parameters<typeof TaskTool.build>[0]) => {
const original = task.execute const base = original(runtime)
task.execute = async (_args, ctx) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return { return {
title: "", id: base.id,
metadata: { async init() {
sessionId: SessionID.make("task"), const next = await base.init()
model: ref, next.execute = async (_args: any, ctx: any) => {
ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {})
return {
title: "",
metadata: {
sessionId: SessionID.make("task"),
model: ref,
},
output: "",
}
}
return next
}, },
output: "",
} }
} }) as typeof TaskTool.build
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) yield* Effect.addFinalizer(() => Effect.sync(() => void (TaskTool.build = original)))
const { prompt, chat } = yield* boot() const { prompt, chat } = yield* boot()
const msg = yield* user(chat.id, "hello") const msg = yield* user(chat.id, "hello")
@@ -12,12 +12,14 @@
* tools internally during multi-step processing before emitting events. * tools internally during multi-step processing before emitting events.
*/ */
import { expect } from "bun:test" import { expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session/llm"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
import { SessionSummary } from "../../src/session/summary" import { SessionSummary } from "../../src/session/summary"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
@@ -27,7 +29,6 @@ import { TestLLMServer } from "../lib/llm-server"
// Same layer setup as prompt-effect.test.ts // Same layer setup as prompt-effect.test.ts
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Layer } from "effect"
import { Agent as AgentSvc } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Command } from "../../src/command" import { Command } from "../../src/command"
@@ -39,6 +40,7 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider" import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Skill } from "../../src/skill"
import { Todo } from "../../src/session/todo" import { Todo } from "../../src/session/todo"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
@@ -131,6 +133,8 @@ function makeHttp() {
const question = Question.layer.pipe(Layer.provideMerge(deps)) const question = Question.layer.pipe(Layer.provideMerge(deps))
const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe( const registry = ToolRegistry.layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provideMerge(todo), Layer.provideMerge(todo),
Layer.provideMerge(question), Layer.provideMerge(question),
Layer.provideMerge(deps), Layer.provideMerge(deps),
@@ -141,6 +145,7 @@ function makeHttp() {
return Layer.mergeAll( return Layer.mergeAll(
TestLLMServer.layer, TestLLMServer.layer,
SessionPrompt.layer.pipe( SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provideMerge(run), Layer.provideMerge(run),
Layer.provideMerge(compact), Layer.provideMerge(compact),
Layer.provideMerge(proc), Layer.provideMerge(proc),
@@ -3,6 +3,7 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { Git } from "../../src/git"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Storage } from "../../src/storage/storage" import { Storage } from "../../src/storage/storage"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -47,7 +48,7 @@ async function withStorage<T>(
root: string, root: string,
fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>, fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>,
) { ) {
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)))) const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)), Layer.provide(Git.defaultLayer)))
try { try {
return await fn((body) => rt.runPromise(body)) return await fn((body) => rt.runPromise(body))
} finally { } finally {
+15 -6
View File
@@ -5,7 +5,8 @@ import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SkillTool, SkillDescription } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "../../src/tool/registry"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@@ -49,9 +50,11 @@ description: Skill for tool tests.
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const desc = await Effect.runPromise( const desc = await ToolRegistry.tools({
SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }), providerID: "opencode" as any,
) modelID: "gpt-5" as any,
agent: { name: "build", mode: "primary" as const, permission: [], options: {} },
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "")
expect(desc).toContain(`**tool-skill**: Skill for tool tests.`) expect(desc).toContain(`**tool-skill**: Skill for tool tests.`)
}, },
}) })
@@ -92,8 +95,14 @@ description: ${description}
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await Effect.runPromise(SkillDescription(agent)) const load = () =>
const second = await Effect.runPromise(SkillDescription(agent)) ToolRegistry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "")
const first = await load()
const second = await load()
expect(first).toBe(second) expect(first).toBe(second)
+41 -13
View File
@@ -9,7 +9,9 @@ import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskDescription, TaskTool } from "../../src/tool/task" import { TaskTool } from "../../src/tool/task"
import { Tool } from "../../src/tool/tool"
import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -22,8 +24,23 @@ const ref = {
modelID: ModelID.make("test-model"), modelID: ModelID.make("test-model"),
} }
const bindTask = (agent: Agent.Interface, config: Config.Interface) =>
TaskTool.build({
agent,
config,
cancel: (sessionID) => SessionPrompt.cancel(sessionID),
resolvePromptParts: (template) => SessionPrompt.resolvePromptParts(template),
prompt: (input) => SessionPrompt.prompt(input),
})
const it = testEffect( const it = testEffect(
Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer), Layer.mergeAll(
Agent.defaultLayer,
Config.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
ToolRegistry.defaultLayer,
),
) )
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
@@ -92,8 +109,13 @@ describe("tool.task", () => {
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service const agent = yield* Agent.Service
const build = yield* agent.get("build") const build = yield* agent.get("build")
const first = yield* TaskDescription(build) const registry = yield* ToolRegistry.Service
const second = yield* TaskDescription(build) const get = Effect.fnUntraced(function* () {
const tools = yield* registry.tools({ ...ref, agent: build })
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
})
const first = yield* get()
const second = yield* get()
expect(first).toBe(second) expect(first).toBe(second)
@@ -130,7 +152,9 @@ describe("tool.task", () => {
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service const agent = yield* Agent.Service
const build = yield* agent.get("build") const build = yield* agent.get("build")
const description = yield* TaskDescription(build) const registry = yield* ToolRegistry.Service
const description =
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
expect(description).toContain("- alpha: Alpha agent") expect(description).toContain("- alpha: Alpha agent")
expect(description).not.toContain("- zebra: Zebra agent") expect(description).not.toContain("- zebra: Zebra agent")
@@ -161,11 +185,12 @@ describe("tool.task", () => {
it.live("execute resumes an existing task session from task_id", () => it.live("execute resumes an existing task session from task_id", () =>
provideTmpdirInstance(() => provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool const def = yield* Tool.init(bindTask(agent, config))
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
@@ -215,9 +240,10 @@ describe("tool.task", () => {
it.live("execute asks by default and skips checks when bypassed", () => it.live("execute asks by default and skips checks when bypassed", () =>
provideTmpdirInstance(() => provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const def = yield* Tool.init(bindTask(agent, config))
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt const prompt = SessionPrompt.prompt
const calls: unknown[] = [] const calls: unknown[] = []
@@ -274,10 +300,11 @@ describe("tool.task", () => {
it.live("execute creates a child when task_id does not exist", () => it.live("execute creates a child when task_id does not exist", () =>
provideTmpdirInstance(() => provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const def = yield* Tool.init(bindTask(agent, config))
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
@@ -328,10 +355,11 @@ describe("tool.task", () => {
provideTmpdirInstance( provideTmpdirInstance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const def = yield* Tool.init(bindTask(agent, config))
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
+13 -3
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } 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"
@@ -22,6 +24,14 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
await fn(server.url) await fn(server.url)
} }
function initTool() {
return WebFetchTool.pipe(
Effect.flatMap((info) => Effect.promise(() => info.init())),
Effect.provide(FetchHttpClient.layer),
Effect.runPromise,
)
}
describe("tool.webfetch", () => { describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => { test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
@@ -31,7 +41,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() const webfetch = await initTool()
const result = await webfetch.execute( const result = await webfetch.execute(
{ url: new URL("/image.png", url).toString(), format: "markdown" }, { url: new URL("/image.png", url).toString(), format: "markdown" },
ctx, ctx,
@@ -63,7 +73,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() const webfetch = await initTool()
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx) const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
expect(result.output).toContain("<svg") expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
@@ -84,7 +94,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() const webfetch = await initTool()
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx) const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
expect(result.output).toBe("hello from webfetch") expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
+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/plugin", "name": "@opencode-ai/plugin",
"version": "1.4.2", "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/sdk", "name": "@opencode-ai/sdk",
"version": "1.4.2", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+236 -234
View File
@@ -94,35 +94,6 @@ export type EventMessagePartDelta = {
} }
} }
export type SessionStatus =
| {
type: "idle"
}
| {
type: "retry"
attempt: number
message: string
next: number
}
| {
type: "busy"
}
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type PermissionRequest = { export type PermissionRequest = {
id: string id: string
sessionID: string sessionID: string
@@ -152,201 +123,6 @@ export type EventPermissionReplied = {
} }
} }
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
}
export type EventTuiSessionSelect = {
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type EventMcpToolsChanged = {
type: "mcp.tools.changed"
properties: {
server: string
}
}
export type EventMcpBrowserOpenFailed = {
type: "mcp.browser.open.failed"
properties: {
mcpName: string
url: string
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}
export type SnapshotFileDiff = { export type SnapshotFileDiff = {
file: string file: string
patch: string patch: string
@@ -439,6 +215,21 @@ export type EventSessionError = {
} }
} }
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventVcsBranchUpdated = { export type EventVcsBranchUpdated = {
type: "vcs.branch.updated" type: "vcs.branch.updated"
properties: { properties: {
@@ -446,6 +237,85 @@ export type EventVcsBranchUpdated = {
} }
} }
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
}
export type EventTuiSessionSelect = {
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type EventMcpToolsChanged = {
type: "mcp.tools.changed"
properties: {
server: string
}
}
export type EventMcpBrowserOpenFailed = {
type: "mcp.browser.open.failed"
properties: {
mcpName: string
url: string
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}
export type EventWorkspaceReady = { export type EventWorkspaceReady = {
type: "workspace.ready" type: "workspace.ready"
properties: { properties: {
@@ -460,6 +330,136 @@ export type EventWorkspaceFailed = {
} }
} }
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type SessionStatus =
| {
type: "idle"
}
| {
type: "retry"
attempt: number
message: string
next: number
}
| {
type: "busy"
}
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
}
}
export type Todo = {
/**
* Brief description of the task
*/
content: string
/**
* Current status of the task: pending, in_progress, completed, cancelled
*/
status: string
/**
* Priority level of the task: high, medium, low
*/
priority: string
}
export type EventTodoUpdated = {
type: "todo.updated"
properties: {
sessionID: string
todos: Array<Todo>
}
}
export type Pty = { export type Pty = {
id: string id: string
title: string title: string
@@ -972,17 +972,13 @@ export type Event =
| EventLspClientDiagnostics | EventLspClientDiagnostics
| EventLspUpdated | EventLspUpdated
| EventMessagePartDelta | EventMessagePartDelta
| EventSessionStatus
| EventSessionIdle
| EventPermissionAsked | EventPermissionAsked
| EventPermissionReplied | EventPermissionReplied
| EventQuestionAsked | EventSessionDiff
| EventQuestionReplied | EventSessionError
| EventQuestionRejected
| EventSessionCompacted
| EventFileEdited | EventFileEdited
| EventFileWatcherUpdated | EventFileWatcherUpdated
| EventTodoUpdated | EventVcsBranchUpdated
| EventTuiPromptAppend | EventTuiPromptAppend
| EventTuiCommandExecute | EventTuiCommandExecute
| EventTuiToastShow | EventTuiToastShow
@@ -990,11 +986,15 @@ export type Event =
| EventMcpToolsChanged | EventMcpToolsChanged
| EventMcpBrowserOpenFailed | EventMcpBrowserOpenFailed
| EventCommandExecuted | EventCommandExecuted
| EventSessionDiff
| EventSessionError
| EventVcsBranchUpdated
| EventWorkspaceReady | EventWorkspaceReady
| EventWorkspaceFailed | EventWorkspaceFailed
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTodoUpdated
| EventPtyCreated | EventPtyCreated
| EventPtyUpdated | EventPtyUpdated
| EventPtyExited | EventPtyExited
@@ -1011,6 +1011,8 @@ export type Event =
export type GlobalEvent = { export type GlobalEvent = {
directory: string directory: string
project?: string
workspace?: string
payload: Event payload: Event
} }
+570 -564
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.4.2", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.4.2", "version": "1.4.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.4.2", "version": "1.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.4.2", "version": "1.4.3",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.4.2", "version": "1.4.3",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",
+17
View File
@@ -0,0 +1,17 @@
# Session API
## Remove Dedicated `session.init` Route
The dedicated `POST /session/:sessionID/init` endpoint exists only as a compatibility wrapper around the normal `/init` command flow.
Current behavior:
- the route calls `SessionPrompt.command(...)`
- it sends `Command.Default.INIT`
- it does not provide distinct session-core behavior beyond running the existing init command in an existing session
V2 plan:
- remove the dedicated `session.init` endpoint
- rely on the normal `/init` command flow instead
- avoid reintroducing `Session.initialize`-style special cases in the session service layer