Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d7501a6a3 | ||
|
|
bf601628db | ||
|
|
00e39d2114 | ||
|
|
46b74e0873 | ||
|
|
aedc4e964f | ||
|
|
e83404367c | ||
|
|
42206da1f8 | ||
|
|
44f38193c0 | ||
|
|
9a6b455bfe | ||
|
|
8063e0b5c6 | ||
|
|
157c5d77f8 | ||
|
|
ce19c051be | ||
|
|
91786d2fc1 | ||
|
|
eca11ca71a | ||
|
|
17bd16667c | ||
|
|
16c60c9ee7 | ||
|
|
0970b102e1 | ||
|
|
04074d3f4a | ||
|
|
b16ee08fd5 | ||
|
|
98874a09f7 | ||
|
|
877be7e8e0 | ||
|
|
eac50f9151 | ||
|
|
1a902b291c | ||
|
|
bbe4a04f9f | ||
|
|
b2f621b897 | ||
|
|
7202b3a325 | ||
|
|
35b44df94a | ||
|
|
10441efad1 | ||
|
|
3199383eef | ||
|
|
9f54115c5d | ||
|
|
2ecc6ae65f | ||
|
|
02b32e1ba7 | ||
|
|
34b9792654 | ||
|
|
537160dbc0 | ||
|
|
b0600664ab | ||
|
|
581a7692ff | ||
|
|
f73e4d5d31 | ||
|
|
a7743e6467 | ||
|
|
5d3dba666c | ||
|
|
bd53b651a3 | ||
|
|
46da801f30 | ||
|
|
58a99916bb | ||
|
|
c29392d085 | ||
|
|
46f243fea7 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -447,7 +447,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -496,7 +496,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -531,7 +531,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -580,7 +580,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -591,7 +591,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
|||||||
if (checkMode() === "background" || type === "http") return false
|
if (checkMode() === "background" || type === "http") return false
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
|
||||||
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
||||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE
|
|||||||
if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
|
if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
|
||||||
|
|
||||||
const version = process.env.OPENCODE_VERSION
|
const version = process.env.OPENCODE_VERSION
|
||||||
if (!releaseId) throw new Error("OPENCODE_VERSION is required")
|
if (!version) throw new Error("OPENCODE_VERSION is required")
|
||||||
|
|
||||||
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
|
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
|
||||||
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
|
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
|
||||||
@@ -54,7 +54,10 @@ const assets = release.assets ?? []
|
|||||||
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
|
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
|
||||||
|
|
||||||
const latestAsset = assetByName.get("latest.json")
|
const latestAsset = assetByName.get("latest.json")
|
||||||
if (!latestAsset) throw new Error("latest.json asset not found")
|
if (!latestAsset) {
|
||||||
|
console.log("latest.json not found, skipping tauri finalization")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
const latestRes = await fetch(latestAsset.url, {
|
const latestRes = await fetch(latestAsset.url, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.4.1"
|
version = "1.4.3"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.4.1",
|
"version": "1.4.3",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const dir = path.resolve(__dirname, "..")
|
|||||||
|
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
|
await import("./generate.ts")
|
||||||
|
|
||||||
// Load migrations from migration directories
|
// Load migrations from migration directories
|
||||||
const migrationDirs = (
|
const migrationDirs = (
|
||||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||||
|
|||||||
@@ -12,24 +12,11 @@ const dir = path.resolve(__dirname, "..")
|
|||||||
|
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
|
await import("./generate.ts")
|
||||||
|
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
|
|
||||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
|
||||||
// Fetch and generate models.dev snapshot
|
|
||||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
|
||||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
|
||||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "src/provider/models-snapshot.js"),
|
|
||||||
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
|
|
||||||
)
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "src/provider/models-snapshot.d.ts"),
|
|
||||||
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
|
|
||||||
)
|
|
||||||
console.log("Generated models-snapshot.js")
|
|
||||||
|
|
||||||
// Load migrations from migration directories
|
// Load migrations from migration directories
|
||||||
const migrationDirs = (
|
const migrationDirs = (
|
||||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const dir = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
process.chdir(dir)
|
||||||
|
|
||||||
|
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||||
|
// Fetch and generate models.dev snapshot
|
||||||
|
const modelsData = process.env.MODELS_DEV_API_JSON
|
||||||
|
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||||
|
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "src/provider/models-snapshot.js"),
|
||||||
|
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "src/provider/models-snapshot.d.ts"),
|
||||||
|
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
|
||||||
|
)
|
||||||
|
console.log("Generated models-snapshot.js")
|
||||||
@@ -461,28 +461,11 @@ export namespace Account {
|
|||||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(): Promise<Info[]> {
|
|
||||||
return runPromise((service) => service.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function activeOrg(): Promise<ActiveOrg | undefined> {
|
|
||||||
return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
|
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
|
||||||
return runPromise((service) => service.orgsByAccount())
|
return runPromise((service) => service.orgsByAccount())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
|
|
||||||
return runPromise((service) => service.orgs(accountID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
|
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
|
||||||
return runPromise((service) => service.use(accountID, Option.some(orgID)))
|
return runPromise((service) => service.use(accountID, Option.some(orgID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
|
||||||
const t = await runPromise((service) => service.token(accountID))
|
|
||||||
return Option.getOrUndefined(t)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,6 +341,10 @@ export namespace Agent {
|
|||||||
)
|
)
|
||||||
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
||||||
|
|
||||||
|
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||||
|
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
|
||||||
|
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
experimental_telemetry: {
|
experimental_telemetry: {
|
||||||
isEnabled: cfg.experimental?.openTelemetry,
|
isEnabled: cfg.experimental?.openTelemetry,
|
||||||
@@ -350,12 +354,14 @@ export namespace Agent {
|
|||||||
},
|
},
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
messages: [
|
messages: [
|
||||||
...system.map(
|
...(isOpenaiOauth
|
||||||
(item): ModelMessage => ({
|
? []
|
||||||
role: "system",
|
: system.map(
|
||||||
content: item,
|
(item): ModelMessage => ({
|
||||||
}),
|
role: "system",
|
||||||
),
|
content: item,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
|
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
|
||||||
@@ -369,13 +375,12 @@ export namespace Agent {
|
|||||||
}),
|
}),
|
||||||
} satisfies Parameters<typeof generateObject>[0]
|
} satisfies Parameters<typeof generateObject>[0]
|
||||||
|
|
||||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
if (isOpenaiOauth) {
|
||||||
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
|
|
||||||
if (model.providerID === "openai" && authInfo?.type === "oauth") {
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const result = streamObject({
|
const result = streamObject({
|
||||||
...params,
|
...params,
|
||||||
providerOptions: ProviderTransform.providerOptions(resolved, {
|
providerOptions: ProviderTransform.providerOptions(resolved, {
|
||||||
|
instructions: system.join("\n"),
|
||||||
store: false,
|
store: false,
|
||||||
}),
|
}),
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
@@ -393,11 +398,13 @@ export namespace Agent {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
Layer.provide(Provider.defaultLayer),
|
layer.pipe(
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Skill.defaultLayer),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
|
|||||||
event: [
|
event: [
|
||||||
{
|
{
|
||||||
directory?: string
|
directory?: string
|
||||||
|
project?: string
|
||||||
|
workspace?: string
|
||||||
payload: any
|
payload: any
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
|
|||||||
clientId: oauthConfig?.clientId,
|
clientId: oauthConfig?.clientId,
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
scope: oauthConfig?.scope,
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onRedirect: async () => {},
|
onRedirect: async () => {},
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
batch,
|
batch,
|
||||||
Show,
|
Show,
|
||||||
on,
|
on,
|
||||||
onCleanup,
|
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
|||||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||||
import { ErrorComponent } from "@tui/component/error-component"
|
import { ErrorComponent } from "@tui/component/error-component"
|
||||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||||
|
import { ProjectProvider } from "@tui/context/project"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||||
import { StartupLoading } from "@tui/component/startup-loading"
|
import { StartupLoading } from "@tui/component/startup-loading"
|
||||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||||
@@ -54,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv"
|
|||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { writeHeapSnapshot } from "v8"
|
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config/tui"
|
||||||
@@ -216,27 +216,29 @@ export function tui(input: {
|
|||||||
headers={input.headers}
|
headers={input.headers}
|
||||||
events={input.events}
|
events={input.events}
|
||||||
>
|
>
|
||||||
<SyncProvider>
|
<ProjectProvider>
|
||||||
<ThemeProvider mode={mode}>
|
<SyncProvider>
|
||||||
<LocalProvider>
|
<ThemeProvider mode={mode}>
|
||||||
<KeybindProvider>
|
<LocalProvider>
|
||||||
<PromptStashProvider>
|
<KeybindProvider>
|
||||||
<DialogProvider>
|
<PromptStashProvider>
|
||||||
<CommandProvider>
|
<DialogProvider>
|
||||||
<FrecencyProvider>
|
<CommandProvider>
|
||||||
<PromptHistoryProvider>
|
<FrecencyProvider>
|
||||||
<PromptRefProvider>
|
<PromptHistoryProvider>
|
||||||
<App onSnapshot={input.onSnapshot} />
|
<PromptRefProvider>
|
||||||
</PromptRefProvider>
|
<App onSnapshot={input.onSnapshot} />
|
||||||
</PromptHistoryProvider>
|
</PromptRefProvider>
|
||||||
</FrecencyProvider>
|
</PromptHistoryProvider>
|
||||||
</CommandProvider>
|
</FrecencyProvider>
|
||||||
</DialogProvider>
|
</CommandProvider>
|
||||||
</PromptStashProvider>
|
</DialogProvider>
|
||||||
</KeybindProvider>
|
</PromptStashProvider>
|
||||||
</LocalProvider>
|
</KeybindProvider>
|
||||||
</ThemeProvider>
|
</LocalProvider>
|
||||||
</SyncProvider>
|
</ThemeProvider>
|
||||||
|
</SyncProvider>
|
||||||
|
</ProjectProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</TuiConfigProvider>
|
</TuiConfigProvider>
|
||||||
</RouteProvider>
|
</RouteProvider>
|
||||||
@@ -260,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const command = useCommandDialog()
|
const command = useCommandDialog()
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
|
const event = useEvent()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const themeState = useTheme()
|
||||||
@@ -283,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
route,
|
route,
|
||||||
routes,
|
routes,
|
||||||
bump: () => setRouteRev((x) => x + 1),
|
bump: () => setRouteRev((x) => x + 1),
|
||||||
|
event,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
theme: themeState,
|
theme: themeState,
|
||||||
@@ -491,12 +495,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
const current = promptRef.current
|
const current = promptRef.current
|
||||||
// Don't require focus - if there's any text, preserve it
|
// Don't require focus - if there's any text, preserve it
|
||||||
const currentPrompt = current?.current?.input ? current.current : undefined
|
const currentPrompt = current?.current?.input ? current.current : undefined
|
||||||
const workspaceID =
|
|
||||||
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
|
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
type: "home",
|
||||||
initialPrompt: currentPrompt,
|
initialPrompt: currentPrompt,
|
||||||
workspaceID,
|
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -806,11 +807,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||||
command.trigger(evt.properties.command)
|
command.trigger(evt.properties.command)
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
|
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||||
toast.show({
|
toast.show({
|
||||||
title: evt.properties.title,
|
title: evt.properties.title,
|
||||||
message: evt.properties.message,
|
message: evt.properties.message,
|
||||||
@@ -819,14 +820,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
|
event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID: evt.properties.sessionID,
|
sessionID: evt.properties.sessionID,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("session.deleted", (evt) => {
|
event.on("session.deleted", (evt) => {
|
||||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||||
route.navigate({ type: "home" })
|
route.navigate({ type: "home" })
|
||||||
toast.show({
|
toast.show({
|
||||||
@@ -836,7 +837,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("session.error", (evt) => {
|
event.on("session.error", (evt) => {
|
||||||
const error = evt.properties.error
|
const error = evt.properties.error
|
||||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||||
const message = errorMessage(error)
|
const message = errorMessage(error)
|
||||||
@@ -848,7 +849,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on("installation.update-available", async (evt) => {
|
event.on("installation.update-available", async (evt) => {
|
||||||
const version = evt.properties.version
|
const version = evt.properties.version
|
||||||
|
|
||||||
const skipped = kv.get("skipped_version")
|
const skipped = kv.get("skipped_version")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||||
@@ -14,7 +15,7 @@ function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>
|
|||||||
return createOpencodeClient({
|
return createOpencodeClient({
|
||||||
baseUrl: sdk.url,
|
baseUrl: sdk.url,
|
||||||
fetch: sdk.fetch,
|
fetch: sdk.fetch,
|
||||||
directory: sync.data.path.directory || sdk.directory,
|
directory: sync.path.directory || sdk.directory,
|
||||||
experimental_workspaceID: workspaceID,
|
experimental_workspaceID: workspaceID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -149,6 +150,7 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi
|
|||||||
|
|
||||||
export function DialogWorkspaceList() {
|
export function DialogWorkspaceList() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const project = useProject()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -168,8 +170,9 @@ export function DialogWorkspaceList() {
|
|||||||
forceCreate,
|
forceCreate,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function selectWorkspace(workspaceID: string) {
|
async function selectWorkspace(workspaceID: string | null) {
|
||||||
if (workspaceID === "__local__") {
|
if (workspaceID == null) {
|
||||||
|
project.workspace.set(undefined)
|
||||||
if (localCount() > 0) {
|
if (localCount() > 0) {
|
||||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
||||||
return
|
return
|
||||||
@@ -199,12 +202,7 @@ export function DialogWorkspaceList() {
|
|||||||
await open(workspaceID)
|
await open(workspaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentWorkspaceID = createMemo(() => {
|
const currentWorkspaceID = createMemo(() => project.workspace.current())
|
||||||
if (route.data.type === "session") {
|
|
||||||
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
|
||||||
}
|
|
||||||
return "__local__"
|
|
||||||
})
|
|
||||||
|
|
||||||
const localCount = createMemo(
|
const localCount = createMemo(
|
||||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
||||||
@@ -234,7 +232,7 @@ export function DialogWorkspaceList() {
|
|||||||
const options = createMemo(() => [
|
const options = createMemo(() => [
|
||||||
{
|
{
|
||||||
title: "Local",
|
title: "Local",
|
||||||
value: "__local__",
|
value: null,
|
||||||
category: "Workspace",
|
category: "Workspace",
|
||||||
description: "Use the local machine",
|
description: "Use the local machine",
|
||||||
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
||||||
@@ -292,7 +290,7 @@ export function DialogWorkspaceList() {
|
|||||||
keybind: keybind.all.session_delete?.[0],
|
keybind: keybind.all.session_delete?.[0],
|
||||||
title: "delete",
|
title: "delete",
|
||||||
onTrigger: async (option) => {
|
onTrigger: async (option) => {
|
||||||
if (option.value === "__create__" || option.value === "__local__") return
|
if (option.value === "__create__" || option.value === null) return
|
||||||
if (toDelete() !== option.value) {
|
if (toDelete() !== option.value) {
|
||||||
setToDelete(option.value)
|
setToDelete(option.value)
|
||||||
return
|
return
|
||||||
@@ -307,6 +305,7 @@ export function DialogWorkspaceList() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (currentWorkspaceID() === option.value) {
|
if (currentWorkspaceID() === option.value) {
|
||||||
|
project.workspace.set(undefined)
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
type: "home",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export function Autocomplete(props: {
|
|||||||
const width = props.anchor().width - 4
|
const width = props.anchor().width - 4
|
||||||
options.push(
|
options.push(
|
||||||
...sortedFiles.map((item): AutocompleteOption => {
|
...sortedFiles.map((item): AutocompleteOption => {
|
||||||
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
|
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||||
const fullPath = `${baseDir}/${item}`
|
const fullPath = `${baseDir}/${item}`
|
||||||
const urlObj = pathToFileURL(fullPath)
|
const urlObj = pathToFileURL(fullPath)
|
||||||
let filename = item
|
let filename = item
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
|||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { MessageID, PartID } from "@/session/schema"
|
import { MessageID, PartID } from "@/session/schema"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||||
let promptPartTypeId = 0
|
let promptPartTypeId = 0
|
||||||
|
const event = useEvent()
|
||||||
|
|
||||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
input.insertText(evt.properties.text)
|
input.insertText(evt.properties.text)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
|
import { useProject } from "./project"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
|
||||||
export function useDirectory() {
|
export function useDirectory() {
|
||||||
|
const project = useProject()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
return createMemo(() => {
|
return createMemo(() => {
|
||||||
const directory = sync.data.path.directory || process.cwd()
|
const directory = project.instance.path().directory || process.cwd()
|
||||||
const result = directory.replace(Global.Path.home, "~")
|
const result = directory.replace(Global.Path.home, "~")
|
||||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Event } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useProject } from "./project"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
|
export function useEvent() {
|
||||||
|
const project = useProject()
|
||||||
|
const sdk = useSDK()
|
||||||
|
|
||||||
|
function subscribe(handler: (event: Event) => void) {
|
||||||
|
return sdk.event.on("event", (event) => {
|
||||||
|
// Special hack for truly global events
|
||||||
|
if (event.directory === "global") {
|
||||||
|
handler(event.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.workspace.current()) {
|
||||||
|
if (event.workspace === project.workspace.current()) {
|
||||||
|
handler(event.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.directory === project.instance.directory()) {
|
||||||
|
handler(event.payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
|
||||||
|
return subscribe((event) => {
|
||||||
|
if (event.type !== type) return
|
||||||
|
handler(event as Extract<Event, { type: T }>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
on,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { batch } from "solid-js"
|
||||||
|
import type { Path } from "@opencode-ai/sdk"
|
||||||
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "./helper"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
|
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
|
||||||
|
name: "Project",
|
||||||
|
init: () => {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
project: {
|
||||||
|
id: undefined as string | undefined,
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
path: {
|
||||||
|
state: "",
|
||||||
|
config: "",
|
||||||
|
worktree: "",
|
||||||
|
directory: sdk.directory ?? "",
|
||||||
|
} satisfies Path,
|
||||||
|
},
|
||||||
|
workspace: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sync() {
|
||||||
|
const workspace = store.workspace
|
||||||
|
const [path, project] = await Promise.all([
|
||||||
|
sdk.client.path.get({ workspace }),
|
||||||
|
sdk.client.project.current({ workspace }),
|
||||||
|
])
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("instance", "path", reconcile(path.data!))
|
||||||
|
setStore("project", "id", project.data?.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: store,
|
||||||
|
project() {
|
||||||
|
return store.project.id
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
path() {
|
||||||
|
return store.instance.path
|
||||||
|
},
|
||||||
|
directory() {
|
||||||
|
return store.instance.path.directory
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
current() {
|
||||||
|
return store.workspace
|
||||||
|
},
|
||||||
|
set(next?: string | null) {
|
||||||
|
const workspace = next ?? undefined
|
||||||
|
if (store.workspace === workspace) return
|
||||||
|
setStore("workspace", workspace)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sync,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
|
|||||||
export type HomeRoute = {
|
export type HomeRoute = {
|
||||||
type: "home"
|
type: "home"
|
||||||
initialPrompt?: PromptInfo
|
initialPrompt?: PromptInfo
|
||||||
workspaceID?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionRoute = {
|
export type SessionRoute = {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
|
||||||
export type EventSource = {
|
export type EventSource = {
|
||||||
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
|
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
let sdk = createSDK()
|
let sdk = createSDK()
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
event: GlobalEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
let queue: Event[] = []
|
let queue: GlobalEvent[] = []
|
||||||
let timer: Timer | undefined
|
let timer: Timer | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
||||||
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
// Batch all event emissions so all store updates result in a single render
|
// Batch all event emissions so all store updates result in a single render
|
||||||
batch(() => {
|
batch(() => {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
emitter.emit(event.type, event)
|
emitter.emit("event", event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEvent = (event: Event) => {
|
const handleEvent = (event: GlobalEvent) => {
|
||||||
queue.push(event)
|
queue.push(event)
|
||||||
const elapsed = Date.now() - last
|
const elapsed = Date.now() - last
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
;(async () => {
|
;(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||||
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
const events = await sdk.global.event({ signal: ctrl.signal })
|
||||||
|
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
if (ctrl.signal.aborted) break
|
if (ctrl.signal.aborted) break
|
||||||
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (props.events) {
|
if (props.events) {
|
||||||
const unsub = await props.events.subscribe(props.directory, handleEvent)
|
const unsub = await props.events.subscribe(handleEvent)
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
} else {
|
} else {
|
||||||
startSSE()
|
startSSE()
|
||||||
|
|||||||
@@ -17,18 +17,19 @@ import type {
|
|||||||
ProviderListResponse,
|
ProviderListResponse,
|
||||||
ProviderAuthMethod,
|
ProviderAuthMethod,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
|
Workspace,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import type { Snapshot } from "@/snapshot"
|
import type { Snapshot } from "@/snapshot"
|
||||||
import { useExit } from "./exit"
|
import { useExit } from "./exit"
|
||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
import { batch, onMount } from "solid-js"
|
import { batch, createEffect, on } from "solid-js"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import type { Path } from "@opencode-ai/sdk"
|
|
||||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
|
||||||
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
@@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
[key: string]: McpResource
|
[key: string]: McpResource
|
||||||
}
|
}
|
||||||
formatter: FormatterStatus[]
|
formatter: FormatterStatus[]
|
||||||
vcs: VcsInfo | undefined
|
|
||||||
path: Path
|
|
||||||
workspaceList: Workspace[]
|
workspaceList: Workspace[]
|
||||||
|
vcs: VcsInfo | undefined
|
||||||
}>({
|
}>({
|
||||||
provider_next: {
|
provider_next: {
|
||||||
all: [],
|
all: [],
|
||||||
@@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
mcp: {},
|
mcp: {},
|
||||||
mcp_resource: {},
|
mcp_resource: {},
|
||||||
formatter: [],
|
formatter: [],
|
||||||
vcs: undefined,
|
|
||||||
path: { state: "", config: "", worktree: "", directory: "" },
|
|
||||||
workspaceList: [],
|
workspaceList: [],
|
||||||
|
vcs: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const event = useEvent()
|
||||||
|
const project = useProject()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
async function syncWorkspaces() {
|
async function syncWorkspaces() {
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||||
if (!result?.data) return
|
if (!result?.data) return
|
||||||
setStore("workspaceList", reconcile(result.data))
|
setStore("workspaceList", reconcile(result.data))
|
||||||
|
if (!result.data.some((item) => item.id === workspace)) {
|
||||||
|
project.workspace.set(undefined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sdk.event.listen((e) => {
|
event.subscribe((event) => {
|
||||||
const event = e.details
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "server.instance.disposed":
|
case "server.instance.disposed":
|
||||||
bootstrap()
|
bootstrap()
|
||||||
@@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "lsp.updated": {
|
case "lsp.updated": {
|
||||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
|
const workspace = project.workspace.current()
|
||||||
|
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
console.log("bootstrapping")
|
console.log("bootstrapping")
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||||
const sessionListPromise = sdk.client.session
|
const sessionListPromise = sdk.client.session
|
||||||
.list({ start: start })
|
.list({ start: start, workspace })
|
||||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||||
|
|
||||||
// blocking - include session.list when continuing a session
|
// blocking - include session.list when continuing a session
|
||||||
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
|
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
|
||||||
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
|
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||||
const consoleStatePromise = sdk.client.experimental.console
|
const consoleStatePromise = sdk.client.experimental.console
|
||||||
.get({}, { throwOnError: true })
|
.get({ workspace }, { throwOnError: true })
|
||||||
.then((x) => ConsoleState.parse(x.data))
|
.then((x) => ConsoleState.parse(x.data))
|
||||||
.catch(() => emptyConsoleState)
|
.catch(() => emptyConsoleState)
|
||||||
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
|
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||||
const configPromise = sdk.client.config.get({}, { throwOnError: true })
|
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||||
|
const projectPromise = project.sync()
|
||||||
const blockingRequests: Promise<unknown>[] = [
|
const blockingRequests: Promise<unknown>[] = [
|
||||||
providersPromise,
|
providersPromise,
|
||||||
providerListPromise,
|
providerListPromise,
|
||||||
agentsPromise,
|
agentsPromise,
|
||||||
configPromise,
|
configPromise,
|
||||||
|
projectPromise,
|
||||||
...(args.continue ? [sessionListPromise] : []),
|
...(args.continue ? [sessionListPromise] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
||||||
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
||||||
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
|
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||||
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
|
||||||
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
|
||||||
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
sdk.client.experimental.resource
|
||||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
.list({ workspace })
|
||||||
sdk.client.session.status().then((x) => {
|
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||||
|
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
|
||||||
|
sdk.client.session.status({ workspace }).then((x) => {
|
||||||
setStore("session_status", reconcile(x.data!))
|
setStore("session_status", reconcile(x.data!))
|
||||||
}),
|
}),
|
||||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
|
||||||
syncWorkspaces(),
|
syncWorkspaces(),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
@@ -449,11 +458,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
bootstrap()
|
|
||||||
})
|
|
||||||
|
|
||||||
const fullSyncedSessions = new Set<string>()
|
const fullSyncedSessions = new Set<string>()
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => project.workspace.current(),
|
||||||
|
() => {
|
||||||
|
fullSyncedSessions.clear()
|
||||||
|
void bootstrap()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
data: store,
|
data: store,
|
||||||
set: setStore,
|
set: setStore,
|
||||||
@@ -463,6 +478,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
get ready() {
|
get ready() {
|
||||||
return store.status !== "loading"
|
return store.status !== "loading"
|
||||||
},
|
},
|
||||||
|
get path() {
|
||||||
|
return project.instance.path()
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
get(sessionID: string) {
|
get(sessionID: string) {
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||||
@@ -481,11 +499,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
if (fullSyncedSessions.has(sessionID)) return
|
if (fullSyncedSessions.has(sessionID)) return
|
||||||
|
const workspace = project.workspace.current()
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
|
||||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
|
||||||
sdk.client.session.todo({ sessionID }),
|
sdk.client.session.todo({ sessionID, workspace }),
|
||||||
sdk.client.session.diff({ sessionID }),
|
sdk.client.session.diff({ sessionID, workspace }),
|
||||||
])
|
])
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -504,8 +523,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
|
list() {
|
||||||
|
return store.workspaceList
|
||||||
|
},
|
||||||
get(workspaceID: string) {
|
get(workspaceID: string) {
|
||||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
return store.workspaceList.find((item) => item.id === workspaceID)
|
||||||
},
|
},
|
||||||
sync: syncWorkspaces,
|
sync: syncWorkspaces,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ParsedKey } from "@opentui/core"
|
import type { ParsedKey } from "@opentui/core"
|
||||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||||
|
import type { useEvent } from "@tui/context/event"
|
||||||
import type { useKeybind } from "@tui/context/keybind"
|
import type { useKeybind } from "@tui/context/keybind"
|
||||||
import type { useRoute } from "@tui/context/route"
|
import type { useRoute } from "@tui/context/route"
|
||||||
import type { useSDK } from "@tui/context/sdk"
|
import type { useSDK } from "@tui/context/sdk"
|
||||||
@@ -36,6 +37,7 @@ type Input = {
|
|||||||
route: ReturnType<typeof useRoute>
|
route: ReturnType<typeof useRoute>
|
||||||
routes: RouteMap
|
routes: RouteMap
|
||||||
bump: () => void
|
bump: () => void
|
||||||
|
event: ReturnType<typeof useEvent>
|
||||||
sdk: ReturnType<typeof useSDK>
|
sdk: ReturnType<typeof useSDK>
|
||||||
sync: ReturnType<typeof useSync>
|
sync: ReturnType<typeof useSync>
|
||||||
theme: ReturnType<typeof useTheme>
|
theme: ReturnType<typeof useTheme>
|
||||||
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
|||||||
return sync.data.provider
|
return sync.data.provider
|
||||||
},
|
},
|
||||||
get path() {
|
get path() {
|
||||||
return sync.data.path
|
return sync.path
|
||||||
},
|
},
|
||||||
get vcs() {
|
get vcs() {
|
||||||
if (!sync.data.vcs) return
|
if (!sync.data.vcs) return
|
||||||
@@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
|||||||
get client() {
|
get client() {
|
||||||
return input.sdk.client
|
return input.sdk.client
|
||||||
},
|
},
|
||||||
event: input.sdk.event,
|
event: input.event,
|
||||||
renderer: input.renderer,
|
renderer: input.renderer,
|
||||||
slots: {
|
slots: {
|
||||||
register() {
|
register() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import { createEffect, createSignal } from "solid-js"
|
import { createEffect, createSignal } from "solid-js"
|
||||||
import { Logo } from "../component/logo"
|
import { Logo } from "../component/logo"
|
||||||
|
import { useProject } from "../context/project"
|
||||||
import { useSync } from "../context/sync"
|
import { useSync } from "../context/sync"
|
||||||
import { Toast } from "../ui/toast"
|
import { Toast } from "../ui/toast"
|
||||||
import { useArgs } from "../context/args"
|
import { useArgs } from "../context/args"
|
||||||
@@ -18,6 +19,7 @@ const placeholder = {
|
|||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const project = useProject()
|
||||||
const route = useRouteData("home")
|
const route = useRouteData("home")
|
||||||
const promptRef = usePromptRef()
|
const promptRef = usePromptRef()
|
||||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||||
@@ -63,11 +65,16 @@ export function Home() {
|
|||||||
</box>
|
</box>
|
||||||
<box height={1} minHeight={0} flexShrink={1} />
|
<box height={1} minHeight={0} flexShrink={1} />
|
||||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
|
<TuiPluginRuntime.Slot
|
||||||
|
name="home_prompt"
|
||||||
|
mode="replace"
|
||||||
|
workspace_id={project.workspace.current()}
|
||||||
|
ref={bind}
|
||||||
|
>
|
||||||
<Prompt
|
<Prompt
|
||||||
ref={bind}
|
ref={bind}
|
||||||
workspaceID={route.workspaceID}
|
workspaceID={project.workspace.current()}
|
||||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
|
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
|
||||||
placeholders={placeholder}
|
placeholders={placeholder}
|
||||||
/>
|
/>
|
||||||
</TuiPluginRuntime.Slot>
|
</TuiPluginRuntime.Slot>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useRoute, useRouteData } from "@tui/context/route"
|
import { useRoute, useRouteData } from "@tui/context/route"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { useEvent } from "@tui/context/event"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||||
@@ -116,6 +118,8 @@ export function Session() {
|
|||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const { navigate } = useRoute()
|
const { navigate } = useRoute()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const event = useEvent()
|
||||||
|
const project = useProject()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -155,7 +159,7 @@ export function Session() {
|
|||||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
|
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||||
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
||||||
@@ -172,10 +176,16 @@ export function Session() {
|
|||||||
const providers = createMemo(() => Model.index(sync.data.provider))
|
const providers = createMemo(() => Model.index(sync.data.provider))
|
||||||
|
|
||||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||||
|
const toast = useToast()
|
||||||
|
const sdk = useSDK()
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
await sync.session
|
await sdk.client.session
|
||||||
.sync(route.sessionID)
|
.get({ sessionID: route.sessionID }, { throwOnError: true })
|
||||||
|
.then((x) => {
|
||||||
|
project.workspace.set(x.data?.workspaceID)
|
||||||
|
})
|
||||||
|
.then(() => sync.session.sync(route.sessionID))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (scroll) scroll.scrollBy(100_000)
|
if (scroll) scroll.scrollBy(100_000)
|
||||||
})
|
})
|
||||||
@@ -189,13 +199,10 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
const sdk = useSDK()
|
|
||||||
|
|
||||||
// Handle initial prompt from fork
|
// Handle initial prompt from fork
|
||||||
let seeded = false
|
let seeded = false
|
||||||
let lastSwitch: string | undefined = undefined
|
let lastSwitch: string | undefined = undefined
|
||||||
sdk.event.on("message.part.updated", (evt) => {
|
event.on("message.part.updated", (evt) => {
|
||||||
const part = evt.properties.part
|
const part = evt.properties.part
|
||||||
if (part.type !== "tool") return
|
if (part.type !== "tool") return
|
||||||
if (part.sessionID !== route.sessionID) return
|
if (part.sessionID !== route.sessionID) return
|
||||||
@@ -224,7 +231,7 @@ export function Session() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
sdk.event.on("session.status", (evt) => {
|
event.on("session.status", (evt) => {
|
||||||
if (evt.properties.sessionID !== route.sessionID) return
|
if (evt.properties.sessionID !== route.sessionID) return
|
||||||
if (evt.properties.status.type !== "retry") return
|
if (evt.properties.status.type !== "retry") return
|
||||||
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
||||||
@@ -1791,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
|
|||||||
const workdir = props.input.workdir
|
const workdir = props.input.workdir
|
||||||
if (!workdir || workdir === ".") return undefined
|
if (!workdir || workdir === ".") return undefined
|
||||||
|
|
||||||
const base = sync.data.path.directory
|
const base = sync.path.directory
|
||||||
if (!base) return undefined
|
if (!base) return undefined
|
||||||
|
|
||||||
const absolute = path.resolve(base, workdir)
|
const absolute = path.resolve(base, workdir)
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -399,6 +399,10 @@ export namespace Config {
|
|||||||
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
|
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
|
||||||
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
|
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
|
||||||
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
|
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
|
||||||
|
redirectUri: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Context } from "../util/context"
|
||||||
|
import type { WorkspaceID } from "../control-plane/schema"
|
||||||
|
|
||||||
|
export interface WorkspaceContext {
|
||||||
|
workspaceID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = Context.create<WorkspaceContext>("instance")
|
||||||
|
|
||||||
|
export const WorkspaceContext = {
|
||||||
|
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
|
||||||
|
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
|
||||||
|
},
|
||||||
|
|
||||||
|
get workspaceID() {
|
||||||
|
try {
|
||||||
|
return context.use().workspaceID
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -134,12 +134,12 @@ export namespace Workspace {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
await parseSSE(res.body, stop, (event) => {
|
// await parseSSE(res.body, stop, (event) => {
|
||||||
GlobalBus.emit("event", {
|
// GlobalBus.emit("event", {
|
||||||
directory: space.id,
|
// directory: space.id,
|
||||||
payload: event,
|
// payload: event,
|
||||||
})
|
// })
|
||||||
})
|
// })
|
||||||
|
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
await sleep(250)
|
await sleep(250)
|
||||||
|
|||||||
@@ -499,4 +499,3 @@ const rt = lazy(async () => {
|
|||||||
|
|
||||||
type RT = Awaited<ReturnType<typeof rt>>
|
type RT = Awaited<ReturnType<typeof rt>>
|
||||||
export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any]))
|
export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any]))
|
||||||
export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any]))
|
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance"
|
|||||||
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||||
defaultValue: () => undefined,
|
defaultValue: () => undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
|
||||||
|
defaultValue: () => undefined,
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
||||||
import { Instance, type InstanceContext } from "@/project/instance"
|
import { Instance, type InstanceContext } from "@/project/instance"
|
||||||
import { Context } from "@/util/context"
|
import { Context } from "@/util/context"
|
||||||
import { InstanceRef } from "./instance-ref"
|
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||||
import { registerDisposer } from "./instance-registry"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
||||||
const TypeId = "~opencode/InstanceState"
|
const TypeId = "~opencode/InstanceState"
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ export namespace InstanceState {
|
|||||||
return (yield* InstanceRef) ?? Instance.current
|
return (yield* InstanceRef) ?? Instance.current
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const workspaceID = Effect.gen(function* () {
|
||||||
|
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
|
||||||
|
})
|
||||||
|
|
||||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||||
|
|
||||||
export const make = <A, E = never, R = never>(
|
export const make = <A, E = never, R = never>(
|
||||||
@@ -73,10 +78,4 @@ export namespace InstanceState {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return yield* ScopedCache.invalidate(self.cache, yield* directory)
|
return yield* ScopedCache.invalidate(self.cache, yield* directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Effect finalizers run on the fiber scheduler after the original async
|
|
||||||
* boundary, so ALS reads like Instance.directory can be gone by then.
|
|
||||||
*/
|
|
||||||
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect"
|
import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
|
||||||
|
|
||||||
export interface Runner<A, E = never> {
|
export interface Runner<A, E = never> {
|
||||||
readonly state: Runner.State<A, E>
|
readonly state: Runner.State<A, E>
|
||||||
readonly busy: boolean
|
readonly busy: boolean
|
||||||
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
|
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||||
readonly startShell: (work: (signal: AbortSignal) => Effect.Effect<A, E>) => Effect.Effect<A, E>
|
readonly startShell: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||||
readonly cancel: Effect.Effect<void>
|
readonly cancel: Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ export namespace Runner {
|
|||||||
interface ShellHandle<A, E> {
|
interface ShellHandle<A, E> {
|
||||||
id: number
|
id: number
|
||||||
fiber: Fiber.Fiber<A, E>
|
fiber: Fiber.Fiber<A, E>
|
||||||
abort: AbortController
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingHandle<A, E> {
|
interface PendingHandle<A, E> {
|
||||||
@@ -100,13 +99,7 @@ export namespace Runner {
|
|||||||
}),
|
}),
|
||||||
).pipe(Effect.flatten)
|
).pipe(Effect.flatten)
|
||||||
|
|
||||||
const stopShell = (shell: ShellHandle<A, E>) =>
|
const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
|
||||||
Effect.gen(function* () {
|
|
||||||
shell.abort.abort()
|
|
||||||
const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis"))
|
|
||||||
if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber)
|
|
||||||
yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid)
|
|
||||||
})
|
|
||||||
|
|
||||||
const ensureRunning = (work: Effect.Effect<A, E>) =>
|
const ensureRunning = (work: Effect.Effect<A, E>) =>
|
||||||
SynchronizedRef.modifyEffect(
|
SynchronizedRef.modifyEffect(
|
||||||
@@ -138,7 +131,7 @@ export namespace Runner {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const startShell = (work: (signal: AbortSignal) => Effect.Effect<A, E>) =>
|
const startShell = (work: Effect.Effect<A, E>) =>
|
||||||
SynchronizedRef.modifyEffect(
|
SynchronizedRef.modifyEffect(
|
||||||
ref,
|
ref,
|
||||||
Effect.fnUntraced(function* (st) {
|
Effect.fnUntraced(function* (st) {
|
||||||
@@ -153,9 +146,8 @@ export namespace Runner {
|
|||||||
}
|
}
|
||||||
yield* busy
|
yield* busy
|
||||||
const id = next()
|
const id = next()
|
||||||
const abort = new AbortController()
|
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
|
||||||
const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
|
const shell = { id, fiber } satisfies ShellHandle<A, E>
|
||||||
const shell = { id, fiber, abort } satisfies ShellHandle<A, E>
|
|
||||||
return [
|
return [
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const exit = yield* Fiber.await(fiber)
|
const exit = yield* Fiber.await(fiber)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -265,39 +265,7 @@ export namespace Git {
|
|||||||
return runPromise((git) => git.run(args, opts))
|
return runPromise((git) => git.run(args, opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function branch(cwd: string) {
|
|
||||||
return runPromise((git) => git.branch(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prefix(cwd: string) {
|
|
||||||
return runPromise((git) => git.prefix(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function defaultBranch(cwd: string) {
|
export async function defaultBranch(cwd: string) {
|
||||||
return runPromise((git) => git.defaultBranch(cwd))
|
return runPromise((git) => git.defaultBranch(cwd))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasHead(cwd: string) {
|
|
||||||
return runPromise((git) => git.hasHead(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mergeBase(cwd: string, base: string, head?: string) {
|
|
||||||
return runPromise((git) => git.mergeBase(cwd, base, head))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function show(cwd: string, ref: string, file: string, prefix?: string) {
|
|
||||||
return runPromise((git) => git.show(cwd, ref, file, prefix))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function status(cwd: string) {
|
|
||||||
return runPromise((git) => git.status(cwd))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function diff(cwd: string, ref: string) {
|
|
||||||
return runPromise((git) => git.diff(cwd, ref))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stats(cwd: string, ref: string) {
|
|
||||||
return runPromise((git) => git.stats(cwd, ref))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ export namespace MCP {
|
|||||||
clientId: oauthConfig?.clientId,
|
clientId: oauthConfig?.clientId,
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
scope: oauthConfig?.scope,
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onRedirect: async (url) => {
|
onRedirect: async (url) => {
|
||||||
@@ -716,13 +717,16 @@ export namespace MCP {
|
|||||||
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
||||||
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||||
|
|
||||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning())
|
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||||
|
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||||
|
|
||||||
|
// Start the callback server with custom redirectUri if configured
|
||||||
|
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
|
||||||
|
|
||||||
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
.join("")
|
.join("")
|
||||||
yield* auth.updateOAuthState(mcpName, oauthState)
|
yield* auth.updateOAuthState(mcpName, oauthState)
|
||||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
|
||||||
let capturedUrl: URL | undefined
|
let capturedUrl: URL | undefined
|
||||||
const authProvider = new McpOAuthProvider(
|
const authProvider = new McpOAuthProvider(
|
||||||
mcpName,
|
mcpName,
|
||||||
@@ -731,6 +735,7 @@ export namespace MCP {
|
|||||||
clientId: oauthConfig?.clientId,
|
clientId: oauthConfig?.clientId,
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
scope: oauthConfig?.scope,
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onRedirect: async (url) => {
|
onRedirect: async (url) => {
|
||||||
@@ -901,9 +906,6 @@ export namespace MCP {
|
|||||||
|
|
||||||
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
|
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
|
||||||
|
|
||||||
export const getPrompt = async (clientName: string, name: string, args?: Record<string, string>) =>
|
|
||||||
runPromise((svc) => svc.getPrompt(clientName, name, args))
|
|
||||||
|
|
||||||
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
|
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
|
||||||
|
|
||||||
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
|
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createConnection } from "net"
|
import { createConnection } from "net"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
||||||
|
|
||||||
const log = Log.create({ service: "mcp.oauth-callback" })
|
const log = Log.create({ service: "mcp.oauth-callback" })
|
||||||
|
|
||||||
|
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
|
||||||
|
let currentPort = OAUTH_CALLBACK_PORT
|
||||||
|
let currentPath = OAUTH_CALLBACK_PATH
|
||||||
|
|
||||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -71,9 +75,9 @@ export namespace McpOAuthCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
|
||||||
|
|
||||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
if (url.pathname !== currentPath) {
|
||||||
res.writeHead(404)
|
res.writeHead(404)
|
||||||
res.end("Not found")
|
res.end("Not found")
|
||||||
return
|
return
|
||||||
@@ -135,19 +139,31 @@ export namespace McpOAuthCallback {
|
|||||||
res.end(HTML_SUCCESS)
|
res.end(HTML_SUCCESS)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureRunning(): Promise<void> {
|
export async function ensureRunning(redirectUri?: string): Promise<void> {
|
||||||
|
// Parse the redirect URI to get port and path (uses defaults if not provided)
|
||||||
|
const { port, path } = parseRedirectUri(redirectUri)
|
||||||
|
|
||||||
|
// If server is running on a different port/path, stop it first
|
||||||
|
if (server && (currentPort !== port || currentPath !== path)) {
|
||||||
|
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
|
||||||
|
await stop()
|
||||||
|
}
|
||||||
|
|
||||||
if (server) return
|
if (server) return
|
||||||
|
|
||||||
const running = await isPortInUse()
|
const running = await isPortInUse(port)
|
||||||
if (running) {
|
if (running) {
|
||||||
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
|
log.info("oauth callback server already running on another instance", { port })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPort = port
|
||||||
|
currentPath = path
|
||||||
|
|
||||||
server = createServer(handleRequest)
|
server = createServer(handleRequest)
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
server!.listen(currentPort, () => {
|
||||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
log.info("oauth callback server started", { port: currentPort, path: currentPath })
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
server!.on("error", reject)
|
server!.on("error", reject)
|
||||||
@@ -182,9 +198,9 @@ export namespace McpOAuthCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isPortInUse(): Promise<boolean> {
|
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
|
const socket = createConnection(port, "127.0.0.1")
|
||||||
socket.on("connect", () => {
|
socket.on("connect", () => {
|
||||||
socket.destroy()
|
socket.destroy()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface McpOAuthConfig {
|
|||||||
clientId?: string
|
clientId?: string
|
||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
scope?: string
|
scope?: string
|
||||||
|
redirectUri?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpOAuthCallbacks {
|
export interface McpOAuthCallbacks {
|
||||||
@@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
get redirectUrl(): string {
|
get redirectUrl(): string {
|
||||||
|
if (this.config.redirectUri) {
|
||||||
|
return this.config.redirectUri
|
||||||
|
}
|
||||||
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
|
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a redirect URI to extract port and path for the callback server.
|
||||||
|
* Returns defaults if the URI can't be parsed.
|
||||||
|
*/
|
||||||
|
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
|
||||||
|
if (!redirectUri) {
|
||||||
|
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(redirectUri)
|
||||||
|
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
|
||||||
|
const path = url.pathname || OAUTH_CALLBACK_PATH
|
||||||
|
return { port, path }
|
||||||
|
} catch {
|
||||||
|
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
"gpt-5.4",
|
"gpt-5.4",
|
||||||
"gpt-5.4-mini",
|
"gpt-5.4-mini",
|
||||||
])
|
])
|
||||||
for (const modelId of Object.keys(provider.models)) {
|
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||||
if (modelId.includes("codex")) continue
|
if (modelId.includes("codex")) continue
|
||||||
if (allowedModels.has(modelId)) continue
|
if (allowedModels.has(model.api.id)) continue
|
||||||
delete provider.models[modelId]
|
delete provider.models[modelId]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -161,39 +161,37 @@ export namespace Vcs {
|
|||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Vcs.state")((ctx) =>
|
Effect.fn("Vcs.state")(function* (ctx) {
|
||||||
Effect.gen(function* () {
|
if (ctx.project.vcs !== "git") {
|
||||||
if (ctx.project.vcs !== "git") {
|
return { current: undefined, root: undefined }
|
||||||
return { current: undefined, root: undefined }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const get = Effect.fnUntraced(function* () {
|
const get = Effect.fnUntraced(function* () {
|
||||||
return yield* git.branch(ctx.directory)
|
return yield* git.branch(ctx.directory)
|
||||||
})
|
})
|
||||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
})
|
})
|
||||||
const value = { current, root }
|
const value = { current, root }
|
||||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||||
|
|
||||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||||
Stream.runForEach((_evt) =>
|
Stream.runForEach((_evt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = yield* get()
|
const next = yield* get()
|
||||||
if (next !== value.current) {
|
if (next !== value.current) {
|
||||||
log.info("branch changed", { from: value.current, to: next })
|
log.info("branch changed", { from: value.current, to: next })
|
||||||
value.current = next
|
value.current = next
|
||||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
)
|
)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
|
|||||||
@@ -22,6 +22,27 @@ export namespace ModelsDev {
|
|||||||
)
|
)
|
||||||
const ttl = 5 * 60 * 1000
|
const ttl = 5 * 60 * 1000
|
||||||
|
|
||||||
|
type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
|
||||||
|
|
||||||
|
const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
|
||||||
|
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const Cost = z.object({
|
||||||
|
input: z.number(),
|
||||||
|
output: z.number(),
|
||||||
|
cache_read: z.number().optional(),
|
||||||
|
cache_write: z.number().optional(),
|
||||||
|
context_over_200k: z
|
||||||
|
.object({
|
||||||
|
input: z.number(),
|
||||||
|
output: z.number(),
|
||||||
|
cache_read: z.number().optional(),
|
||||||
|
cache_write: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const Model = z.object({
|
export const Model = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -41,22 +62,7 @@ export namespace ModelsDev {
|
|||||||
.strict(),
|
.strict(),
|
||||||
])
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
cost: z
|
cost: Cost.optional(),
|
||||||
.object({
|
|
||||||
input: z.number(),
|
|
||||||
output: z.number(),
|
|
||||||
cache_read: z.number().optional(),
|
|
||||||
cache_write: z.number().optional(),
|
|
||||||
context_over_200k: z
|
|
||||||
.object({
|
|
||||||
input: z.number(),
|
|
||||||
output: z.number(),
|
|
||||||
cache_read: z.number().optional(),
|
|
||||||
cache_write: z.number().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
limit: z.object({
|
limit: z.object({
|
||||||
context: z.number(),
|
context: z.number(),
|
||||||
input: z.number().optional(),
|
input: z.number().optional(),
|
||||||
@@ -68,7 +74,24 @@ export namespace ModelsDev {
|
|||||||
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
experimental: z.boolean().optional(),
|
experimental: z
|
||||||
|
.object({
|
||||||
|
modes: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
cost: Cost.optional(),
|
||||||
|
provider: z
|
||||||
|
.object({
|
||||||
|
body: z.record(z.string(), JsonValue).optional(),
|
||||||
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||||
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
|
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -926,6 +926,28 @@ export namespace Provider {
|
|||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
|
||||||
|
|
||||||
|
function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
|
||||||
|
const result: Model["cost"] = {
|
||||||
|
input: c?.input ?? 0,
|
||||||
|
output: c?.output ?? 0,
|
||||||
|
cache: {
|
||||||
|
read: c?.cache_read ?? 0,
|
||||||
|
write: c?.cache_write ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (c?.context_over_200k) {
|
||||||
|
result.experimentalOver200K = {
|
||||||
|
cache: {
|
||||||
|
read: c.context_over_200k.cache_read ?? 0,
|
||||||
|
write: c.context_over_200k.cache_write ?? 0,
|
||||||
|
},
|
||||||
|
input: c.context_over_200k.input,
|
||||||
|
output: c.context_over_200k.output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
||||||
const m: Model = {
|
const m: Model = {
|
||||||
id: ModelID.make(model.id),
|
id: ModelID.make(model.id),
|
||||||
@@ -940,24 +962,7 @@ export namespace Provider {
|
|||||||
status: model.status ?? "active",
|
status: model.status ?? "active",
|
||||||
headers: {},
|
headers: {},
|
||||||
options: {},
|
options: {},
|
||||||
cost: {
|
cost: cost(model.cost),
|
||||||
input: model.cost?.input ?? 0,
|
|
||||||
output: model.cost?.output ?? 0,
|
|
||||||
cache: {
|
|
||||||
read: model.cost?.cache_read ?? 0,
|
|
||||||
write: model.cost?.cache_write ?? 0,
|
|
||||||
},
|
|
||||||
experimentalOver200K: model.cost?.context_over_200k
|
|
||||||
? {
|
|
||||||
cache: {
|
|
||||||
read: model.cost.context_over_200k.cache_read ?? 0,
|
|
||||||
write: model.cost.context_over_200k.cache_write ?? 0,
|
|
||||||
},
|
|
||||||
input: model.cost.context_over_200k.input,
|
|
||||||
output: model.cost.context_over_200k.output,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
limit: {
|
limit: {
|
||||||
context: model.limit.context,
|
context: model.limit.context,
|
||||||
input: model.limit.input,
|
input: model.limit.input,
|
||||||
@@ -994,13 +999,31 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
|
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
|
||||||
|
const models: Record<string, Model> = {}
|
||||||
|
for (const [key, model] of Object.entries(provider.models)) {
|
||||||
|
models[key] = fromModelsDevModel(provider, model)
|
||||||
|
for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) {
|
||||||
|
const id = `${model.id}-${mode}`
|
||||||
|
const m = fromModelsDevModel(provider, model)
|
||||||
|
m.id = ModelID.make(id)
|
||||||
|
m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`
|
||||||
|
if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost))
|
||||||
|
// convert body params to camelCase for ai sdk compatibility
|
||||||
|
if (opts.provider?.body)
|
||||||
|
m.options = Object.fromEntries(
|
||||||
|
Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]),
|
||||||
|
)
|
||||||
|
if (opts.provider?.headers) m.headers = opts.provider.headers
|
||||||
|
models[id] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: ProviderID.make(provider.id),
|
id: ProviderID.make(provider.id),
|
||||||
source: "custom",
|
source: "custom",
|
||||||
name: provider.name,
|
name: provider.name,
|
||||||
env: provider.env ?? [],
|
env: provider.env ?? [],
|
||||||
options: {},
|
options: {},
|
||||||
models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
|
models,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -371,10 +371,6 @@ export namespace Pty {
|
|||||||
return runPromise((svc) => svc.get(id))
|
return runPromise((svc) => svc.get(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resize(id: PtyID, cols: number, rows: number) {
|
|
||||||
return runPromise((svc) => svc.resize(id, cols, rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function write(id: PtyID, data: string) {
|
export async function write(id: PtyID, data: string) {
|
||||||
return runPromise((svc) => svc.write(id, data))
|
return runPromise((svc) => svc.write(id, data))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import z from "zod"
|
|||||||
import { Session } from "../../session"
|
import { Session } from "../../session"
|
||||||
import { MessageV2 } from "../../session/message-v2"
|
import { MessageV2 } from "../../session/message-v2"
|
||||||
import { SessionPrompt } from "../../session/prompt"
|
import { SessionPrompt } from "../../session/prompt"
|
||||||
|
import { SessionRunState } from "@/session/run-state"
|
||||||
import { SessionCompaction } from "../../session/compaction"
|
import { SessionCompaction } from "../../session/compaction"
|
||||||
import { SessionRevert } from "../../session/revert"
|
import { SessionRevert } from "../../session/revert"
|
||||||
|
import { SessionShare } from "@/share/session"
|
||||||
import { SessionStatus } from "@/session/status"
|
import { SessionStatus } from "@/session/status"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { Todo } from "../../session/todo"
|
import { Todo } from "../../session/todo"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
|
import { Command } from "../../command"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
@@ -121,7 +124,6 @@ export const SessionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
log.info("SEARCH", { url: c.req.url })
|
|
||||||
const session = await Session.get(sessionID)
|
const session = await Session.get(sessionID)
|
||||||
return c.json(session)
|
return c.json(session)
|
||||||
},
|
},
|
||||||
@@ -205,10 +207,10 @@ export const SessionRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("json", Session.create.schema.optional()),
|
validator("json", Session.create.schema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json") ?? {}
|
const body = c.req.valid("json") ?? {}
|
||||||
const session = await Session.create(body)
|
const session = await SessionShare.create(body)
|
||||||
return c.json(session)
|
return c.json(session)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -292,6 +294,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
return c.json(session)
|
return c.json(session)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
|
||||||
.post(
|
.post(
|
||||||
"/:sessionID/init",
|
"/:sessionID/init",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -317,11 +320,24 @@ export const SessionRoutes = lazy(() =>
|
|||||||
sessionID: SessionID.zod,
|
sessionID: SessionID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", Session.initialize.schema.omit({ sessionID: true })),
|
validator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
modelID: ModelID.zod,
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await Session.initialize({ ...body, sessionID })
|
await SessionPrompt.command({
|
||||||
|
sessionID,
|
||||||
|
messageID: body.messageID,
|
||||||
|
model: body.providerID + "/" + body.modelID,
|
||||||
|
command: Command.Default.INIT,
|
||||||
|
arguments: "",
|
||||||
|
})
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -411,7 +427,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
await Session.share(sessionID)
|
await SessionShare.share(sessionID)
|
||||||
const session = await Session.get(sessionID)
|
const session = await Session.get(sessionID)
|
||||||
return c.json(session)
|
return c.json(session)
|
||||||
},
|
},
|
||||||
@@ -476,12 +492,12 @@ export const SessionRoutes = lazy(() =>
|
|||||||
validator(
|
validator(
|
||||||
"param",
|
"param",
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: Session.unshare.schema,
|
sessionID: SessionID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
await Session.unshare(sessionID)
|
await SessionShare.unshare(sessionID)
|
||||||
const session = await Session.get(sessionID)
|
const session = await Session.get(sessionID)
|
||||||
return c.json(session)
|
return c.json(session)
|
||||||
},
|
},
|
||||||
@@ -699,7 +715,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
await SessionPrompt.assertNotBusy(params.sessionID)
|
await SessionRunState.assertNotBusy(params.sessionID)
|
||||||
await Session.removeMessage({
|
await Session.removeMessage({
|
||||||
sessionID: params.sessionID,
|
sessionID: params.sessionID,
|
||||||
messageID: params.messageID,
|
messageID: params.messageID,
|
||||||
|
|||||||
@@ -377,17 +377,15 @@ When constructing the summary, try to stick to this template:
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.unwrap(
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
Effect.sync(() =>
|
layer.pipe(
|
||||||
layer.pipe(
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(SessionProcessor.defaultLayer),
|
||||||
Layer.provide(SessionProcessor.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -401,17 +399,6 @@ When constructing the summary, try to stick to this template:
|
|||||||
return runPromise((svc) => svc.prune(input))
|
return runPromise((svc) => svc.prune(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const process = fn(
|
|
||||||
z.object({
|
|
||||||
parentID: MessageID.zod,
|
|
||||||
messages: z.custom<MessageV2.WithParts[]>(),
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
auto: z.boolean(),
|
|
||||||
overflow: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
(input) => runPromise((svc) => svc.process(input)),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const create = fn(
|
export const create = fn(
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: SessionID.zod,
|
sessionID: SessionID.zod,
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import { Bus } from "@/bus"
|
|||||||
import { Decimal } from "decimal.js"
|
import { Decimal } from "decimal.js"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { type ProviderMetadata } from "ai"
|
import { type ProviderMetadata } from "ai"
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
|
|
||||||
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||||
import { SyncEvent } from "../sync"
|
import { SyncEvent } from "../sync"
|
||||||
import type { SQL } from "../storage/db"
|
import type { SQL } from "../storage/db"
|
||||||
import { SessionTable } from "./session.sql"
|
import { PartTable, SessionTable } from "./session.sql"
|
||||||
import { ProjectTable } from "../project/project.sql"
|
import { ProjectTable } from "../project/project.sql"
|
||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
@@ -20,20 +19,17 @@ import { updateSchema } from "../util/update-schema"
|
|||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { SessionPrompt } from "./prompt"
|
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Command } from "../command"
|
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { ProjectID } from "../project/schema"
|
import { ProjectID } from "../project/schema"
|
||||||
import { WorkspaceID } from "../control-plane/schema"
|
import { WorkspaceID } from "../control-plane/schema"
|
||||||
import { SessionID, MessageID, PartID } from "./schema"
|
import { SessionID, MessageID, PartID } from "./schema"
|
||||||
|
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||||
import { Effect, Layer, Scope, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace Session {
|
export namespace Session {
|
||||||
@@ -322,8 +318,6 @@ export namespace Session {
|
|||||||
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
|
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
|
||||||
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
|
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
readonly get: (id: SessionID) => Effect.Effect<Info>
|
readonly get: (id: SessionID) => Effect.Effect<Info>
|
||||||
readonly share: (id: SessionID) => Effect.Effect<{ url: string }>
|
|
||||||
readonly unshare: (id: SessionID) => Effect.Effect<void>
|
|
||||||
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
|
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
|
||||||
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
|
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
|
||||||
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
|
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
|
||||||
@@ -345,6 +339,11 @@ export namespace Session {
|
|||||||
messageID: MessageID
|
messageID: MessageID
|
||||||
partID: PartID
|
partID: PartID
|
||||||
}) => Effect.Effect<PartID>
|
}) => Effect.Effect<PartID>
|
||||||
|
readonly getPart: (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
messageID: MessageID
|
||||||
|
partID: PartID
|
||||||
|
}) => Effect.Effect<MessageV2.Part | undefined>
|
||||||
readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
|
readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
|
||||||
readonly updatePartDelta: (input: {
|
readonly updatePartDelta: (input: {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
@@ -353,12 +352,6 @@ export namespace Session {
|
|||||||
field: string
|
field: string
|
||||||
delta: string
|
delta: string
|
||||||
}) => Effect.Effect<void>
|
}) => Effect.Effect<void>
|
||||||
readonly initialize: (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
modelID: ModelID
|
|
||||||
providerID: ProviderID
|
|
||||||
messageID: MessageID
|
|
||||||
}) => Effect.Effect<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
|
||||||
@@ -368,12 +361,10 @@ export namespace Session {
|
|||||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||||
Effect.sync(() => Database.use(fn))
|
Effect.sync(() => Database.use(fn))
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, Bus.Service | Config.Service> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const config = yield* Config.Service
|
|
||||||
const scope = yield* Scope.Scope
|
|
||||||
|
|
||||||
const createNext = Effect.fn("Session.createNext")(function* (input: {
|
const createNext = Effect.fn("Session.createNext")(function* (input: {
|
||||||
id?: SessionID
|
id?: SessionID
|
||||||
@@ -403,11 +394,6 @@ export namespace Session {
|
|||||||
|
|
||||||
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
|
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
|
||||||
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
|
|
||||||
yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
// This only exist for backwards compatibility. We should not be
|
// This only exist for backwards compatibility. We should not be
|
||||||
// manually publishing this event; it is a sync event now
|
// manually publishing this event; it is a sync event now
|
||||||
@@ -426,25 +412,6 @@ export namespace Session {
|
|||||||
return fromRow(row)
|
return fromRow(row)
|
||||||
})
|
})
|
||||||
|
|
||||||
const share = Effect.fn("Session.share")(function* (id: SessionID) {
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration")
|
|
||||||
const result = yield* Effect.promise(async () => {
|
|
||||||
const { ShareNext } = await import("@/share/share-next")
|
|
||||||
return ShareNext.create(id)
|
|
||||||
})
|
|
||||||
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } }))
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) {
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
const { ShareNext } = await import("@/share/share-next")
|
|
||||||
await ShareNext.remove(id)
|
|
||||||
})
|
|
||||||
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } }))
|
|
||||||
})
|
|
||||||
|
|
||||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const rows = yield* db((d) =>
|
const rows = yield* db((d) =>
|
||||||
@@ -464,7 +431,6 @@ export namespace Session {
|
|||||||
for (const child of kids) {
|
for (const child of kids) {
|
||||||
yield* remove(child.id)
|
yield* remove(child.id)
|
||||||
}
|
}
|
||||||
yield* unshare(sessionID).pipe(Effect.ignore)
|
|
||||||
yield* Effect.sync(() => {
|
yield* Effect.sync(() => {
|
||||||
SyncEvent.run(Event.Deleted, { sessionID, info: session })
|
SyncEvent.run(Event.Deleted, { sessionID, info: session })
|
||||||
SyncEvent.remove(sessionID)
|
SyncEvent.remove(sessionID)
|
||||||
@@ -492,6 +458,29 @@ export namespace Session {
|
|||||||
return part
|
return part
|
||||||
}).pipe(Effect.withSpan("Session.updatePart"))
|
}).pipe(Effect.withSpan("Session.updatePart"))
|
||||||
|
|
||||||
|
const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) {
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(PartTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(PartTable.session_id, input.sessionID),
|
||||||
|
eq(PartTable.message_id, input.messageID),
|
||||||
|
eq(PartTable.id, input.partID),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get(),
|
||||||
|
)
|
||||||
|
if (!row) return
|
||||||
|
return {
|
||||||
|
...row.data,
|
||||||
|
id: row.id,
|
||||||
|
sessionID: row.session_id,
|
||||||
|
messageID: row.message_id,
|
||||||
|
} as MessageV2.Part
|
||||||
|
})
|
||||||
|
|
||||||
const create = Effect.fn("Session.create")(function* (input?: {
|
const create = Effect.fn("Session.create")(function* (input?: {
|
||||||
parentID?: SessionID
|
parentID?: SessionID
|
||||||
title?: string
|
title?: string
|
||||||
@@ -588,7 +577,7 @@ export namespace Session {
|
|||||||
|
|
||||||
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
|
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
|
||||||
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
|
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
|
||||||
Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]),
|
Effect.orElseSucceed((): Snapshot.FileDiff[] => []),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -637,30 +626,11 @@ export namespace Session {
|
|||||||
yield* bus.publish(MessageV2.Event.PartDelta, input)
|
yield* bus.publish(MessageV2.Event.PartDelta, input)
|
||||||
})
|
})
|
||||||
|
|
||||||
const initialize = Effect.fn("Session.initialize")(function* (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
modelID: ModelID
|
|
||||||
providerID: ProviderID
|
|
||||||
messageID: MessageID
|
|
||||||
}) {
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
SessionPrompt.command({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: input.messageID,
|
|
||||||
model: input.providerID + "/" + input.modelID,
|
|
||||||
command: Command.Default.INIT,
|
|
||||||
arguments: "",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
create,
|
create,
|
||||||
fork,
|
fork,
|
||||||
touch,
|
touch,
|
||||||
get,
|
get,
|
||||||
share,
|
|
||||||
unshare,
|
|
||||||
setTitle,
|
setTitle,
|
||||||
setArchived,
|
setArchived,
|
||||||
setPermission,
|
setPermission,
|
||||||
@@ -675,13 +645,13 @@ export namespace Session {
|
|||||||
removeMessage,
|
removeMessage,
|
||||||
removePart,
|
removePart,
|
||||||
updatePart,
|
updatePart,
|
||||||
|
getPart,
|
||||||
updatePartDelta,
|
updatePartDelta,
|
||||||
initialize,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
@@ -701,10 +671,7 @@ export namespace Session {
|
|||||||
runPromise((svc) => svc.fork(input)),
|
runPromise((svc) => svc.fork(input)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id)))
|
|
||||||
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
|
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
|
||||||
export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id)))
|
|
||||||
export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id)))
|
|
||||||
|
|
||||||
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
|
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
|
||||||
runPromise((svc) => svc.setTitle(input)),
|
runPromise((svc) => svc.setTitle(input)),
|
||||||
@@ -714,24 +681,12 @@ export namespace Session {
|
|||||||
runPromise((svc) => svc.setArchived(input)),
|
runPromise((svc) => svc.setArchived(input)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
|
||||||
runPromise((svc) => svc.setPermission(input)),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const setRevert = fn(
|
export const setRevert = fn(
|
||||||
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
||||||
(input) =>
|
(input) =>
|
||||||
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
|
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id)))
|
|
||||||
|
|
||||||
export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) =>
|
|
||||||
runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id)))
|
|
||||||
|
|
||||||
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
|
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
|
||||||
runPromise((svc) => svc.messages(input)),
|
runPromise((svc) => svc.messages(input)),
|
||||||
)
|
)
|
||||||
@@ -879,9 +834,4 @@ export namespace Session {
|
|||||||
}),
|
}),
|
||||||
(input) => runPromise((svc) => svc.updatePartDelta(input)),
|
(input) => runPromise((svc) => svc.updatePartDelta(input)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const initialize = fn(
|
|
||||||
z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }),
|
|
||||||
(input) => runPromise((svc) => svc.initialize(input)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,11 @@ export namespace LLM {
|
|||||||
// from the workflow service are executed via opencode's tool system
|
// from the workflow service are executed via opencode's tool system
|
||||||
// and results sent back over the WebSocket.
|
// and results sent back over the WebSocket.
|
||||||
if (language instanceof GitLabWorkflowLanguageModel) {
|
if (language instanceof GitLabWorkflowLanguageModel) {
|
||||||
const workflowModel = language
|
const workflowModel = language as GitLabWorkflowLanguageModel & {
|
||||||
|
sessionID?: string
|
||||||
|
sessionPreapprovedTools?: string[]
|
||||||
|
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
|
||||||
|
}
|
||||||
workflowModel.sessionID = input.sessionID
|
workflowModel.sessionID = input.sessionID
|
||||||
workflowModel.systemPrompt = system.join("\n")
|
workflowModel.systemPrompt = system.join("\n")
|
||||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||||
@@ -301,7 +305,7 @@ export namespace LLM {
|
|||||||
ruleset: [],
|
ruleset: [],
|
||||||
})
|
})
|
||||||
for (const name of uniqueNames) approvedToolsForSession.add(name)
|
for (const name of uniqueNames) approvedToolsForSession.add(name)
|
||||||
workflowModel.sessionPreapprovedTools = [...workflowModel.sessionPreapprovedTools, ...uniqueNames]
|
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
|
||||||
return { approved: true }
|
return { approved: true }
|
||||||
} catch {
|
} catch {
|
||||||
return { approved: false }
|
return { approved: false }
|
||||||
|
|||||||
@@ -751,16 +751,32 @@ export namespace MessageV2 {
|
|||||||
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (part.state.status === "error")
|
if (part.state.status === "error") {
|
||||||
assistantMessage.parts.push({
|
const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
|
||||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
if (typeof output === "string") {
|
||||||
state: "output-error",
|
assistantMessage.parts.push({
|
||||||
toolCallId: part.callID,
|
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||||
input: part.state.input,
|
state: "output-available",
|
||||||
errorText: part.state.error,
|
toolCallId: part.callID,
|
||||||
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
input: part.state.input,
|
||||||
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
output,
|
||||||
})
|
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
||||||
|
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
assistantMessage.parts.push({
|
||||||
|
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||||
|
state: "output-error",
|
||||||
|
toolCallId: part.callID,
|
||||||
|
input: part.state.input,
|
||||||
|
errorText: part.state.error,
|
||||||
|
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
||||||
|
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle pending/running tool calls to prevent dangling tool_use blocks
|
||||||
|
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
|
||||||
if (part.state.status === "pending" || part.state.status === "running")
|
if (part.state.status === "pending" || part.state.status === "running")
|
||||||
assistantMessage.parts.push({
|
assistantMessage.parts.push({
|
||||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
import { Cause, Deferred, Effect, Layer, ServiceMap } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
@@ -18,6 +18,8 @@ import { SessionStatus } from "./status"
|
|||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
export namespace SessionProcessor {
|
export namespace SessionProcessor {
|
||||||
const DOOM_LOOP_THRESHOLD = 3
|
const DOOM_LOOP_THRESHOLD = 3
|
||||||
@@ -29,7 +31,19 @@ export namespace SessionProcessor {
|
|||||||
|
|
||||||
export interface Handle {
|
export interface Handle {
|
||||||
readonly message: MessageV2.Assistant
|
readonly message: MessageV2.Assistant
|
||||||
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
|
readonly updateToolCall: (
|
||||||
|
toolCallID: string,
|
||||||
|
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
||||||
|
) => Effect.Effect<MessageV2.ToolPart | undefined>
|
||||||
|
readonly completeToolCall: (
|
||||||
|
toolCallID: string,
|
||||||
|
output: {
|
||||||
|
title: string
|
||||||
|
metadata: Record<string, any>
|
||||||
|
output: string
|
||||||
|
attachments?: MessageV2.FilePart[]
|
||||||
|
},
|
||||||
|
) => Effect.Effect<void>
|
||||||
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +57,15 @@ export namespace SessionProcessor {
|
|||||||
readonly create: (input: Input) => Effect.Effect<Handle>
|
readonly create: (input: Input) => Effect.Effect<Handle>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolCall = {
|
||||||
|
partID: MessageV2.ToolPart["id"]
|
||||||
|
messageID: MessageV2.ToolPart["messageID"]
|
||||||
|
sessionID: MessageV2.ToolPart["sessionID"]
|
||||||
|
done: Deferred.Deferred<void>
|
||||||
|
}
|
||||||
|
|
||||||
interface ProcessorContext extends Input {
|
interface ProcessorContext extends Input {
|
||||||
toolcalls: Record<string, MessageV2.ToolPart>
|
toolcalls: Record<string, ToolCall>
|
||||||
shouldBreak: boolean
|
shouldBreak: boolean
|
||||||
snapshot: string | undefined
|
snapshot: string | undefined
|
||||||
blocked: boolean
|
blocked: boolean
|
||||||
@@ -107,6 +128,88 @@ export namespace SessionProcessor {
|
|||||||
aborted,
|
aborted,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
|
||||||
|
const done = ctx.toolcalls[toolCallID]?.done
|
||||||
|
delete ctx.toolcalls[toolCallID]
|
||||||
|
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
|
||||||
|
})
|
||||||
|
|
||||||
|
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
||||||
|
const call = ctx.toolcalls[toolCallID]
|
||||||
|
if (!call) return
|
||||||
|
const part = yield* session.getPart({
|
||||||
|
partID: call.partID,
|
||||||
|
messageID: call.messageID,
|
||||||
|
sessionID: call.sessionID,
|
||||||
|
})
|
||||||
|
if (!part || part.type !== "tool") {
|
||||||
|
delete ctx.toolcalls[toolCallID]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return { call, part }
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
|
||||||
|
toolCallID: string,
|
||||||
|
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
||||||
|
) {
|
||||||
|
const match = yield* readToolCall(toolCallID)
|
||||||
|
if (!match) return
|
||||||
|
const part = yield* session.updatePart(update(match.part))
|
||||||
|
ctx.toolcalls[toolCallID] = {
|
||||||
|
...match.call,
|
||||||
|
partID: part.id,
|
||||||
|
messageID: part.messageID,
|
||||||
|
sessionID: part.sessionID,
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
|
||||||
|
const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* (
|
||||||
|
toolCallID: string,
|
||||||
|
output: {
|
||||||
|
title: string
|
||||||
|
metadata: Record<string, any>
|
||||||
|
output: string
|
||||||
|
attachments?: MessageV2.FilePart[]
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const match = yield* readToolCall(toolCallID)
|
||||||
|
if (!match || match.part.state.status !== "running") return
|
||||||
|
yield* session.updatePart({
|
||||||
|
...match.part,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: match.part.state.input,
|
||||||
|
output: output.output,
|
||||||
|
metadata: output.metadata,
|
||||||
|
title: output.title,
|
||||||
|
time: { start: match.part.state.time.start, end: Date.now() },
|
||||||
|
attachments: output.attachments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
yield* settleToolCall(toolCallID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {
|
||||||
|
const match = yield* readToolCall(toolCallID)
|
||||||
|
if (!match || match.part.state.status !== "running") return false
|
||||||
|
yield* session.updatePart({
|
||||||
|
...match.part,
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
input: match.part.state.input,
|
||||||
|
error: errorMessage(error),
|
||||||
|
time: { start: match.part.state.time.start, end: Date.now() },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
|
||||||
|
ctx.blocked = ctx.shouldBreak
|
||||||
|
}
|
||||||
|
yield* settleToolCall(toolCallID)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
|
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case "start":
|
case "start":
|
||||||
@@ -153,8 +256,8 @@ export namespace SessionProcessor {
|
|||||||
if (ctx.assistantMessage.summary) {
|
if (ctx.assistantMessage.summary) {
|
||||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||||
}
|
}
|
||||||
ctx.toolcalls[value.id] = yield* session.updatePart({
|
const part = yield* session.updatePart({
|
||||||
id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(),
|
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
|
||||||
messageID: ctx.assistantMessage.id,
|
messageID: ctx.assistantMessage.id,
|
||||||
sessionID: ctx.assistantMessage.sessionID,
|
sessionID: ctx.assistantMessage.sessionID,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
@@ -163,6 +266,12 @@ export namespace SessionProcessor {
|
|||||||
state: { status: "pending", input: {}, raw: "" },
|
state: { status: "pending", input: {}, raw: "" },
|
||||||
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
|
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
|
||||||
} satisfies MessageV2.ToolPart)
|
} satisfies MessageV2.ToolPart)
|
||||||
|
ctx.toolcalls[value.id] = {
|
||||||
|
done: yield* Deferred.make<void>(),
|
||||||
|
partID: part.id,
|
||||||
|
messageID: part.messageID,
|
||||||
|
sessionID: part.sessionID,
|
||||||
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
case "tool-input-delta":
|
case "tool-input-delta":
|
||||||
@@ -175,16 +284,19 @@ export namespace SessionProcessor {
|
|||||||
if (ctx.assistantMessage.summary) {
|
if (ctx.assistantMessage.summary) {
|
||||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||||
}
|
}
|
||||||
const match = ctx.toolcalls[value.toolCallId]
|
yield* updateToolCall(value.toolCallId, (match) => ({
|
||||||
if (!match) return
|
|
||||||
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
|
|
||||||
...match,
|
...match,
|
||||||
tool: value.toolName,
|
tool: value.toolName,
|
||||||
state: { status: "running", input: value.input, time: { start: Date.now() } },
|
state: {
|
||||||
|
...match.state,
|
||||||
|
status: "running",
|
||||||
|
input: value.input,
|
||||||
|
time: { start: Date.now() },
|
||||||
|
},
|
||||||
metadata: match.metadata?.providerExecuted
|
metadata: match.metadata?.providerExecuted
|
||||||
? { ...value.providerMetadata, providerExecuted: true }
|
? { ...value.providerMetadata, providerExecuted: true }
|
||||||
: value.providerMetadata,
|
: value.providerMetadata,
|
||||||
} satisfies MessageV2.ToolPart)
|
}))
|
||||||
|
|
||||||
const parts = MessageV2.parts(ctx.assistantMessage.id)
|
const parts = MessageV2.parts(ctx.assistantMessage.id)
|
||||||
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
|
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
|
||||||
@@ -215,40 +327,12 @@ export namespace SessionProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "tool-result": {
|
case "tool-result": {
|
||||||
const match = ctx.toolcalls[value.toolCallId]
|
yield* completeToolCall(value.toolCallId, value.output)
|
||||||
if (!match || match.state.status !== "running") return
|
|
||||||
yield* session.updatePart({
|
|
||||||
...match,
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: value.input ?? match.state.input,
|
|
||||||
output: value.output.output,
|
|
||||||
metadata: value.output.metadata,
|
|
||||||
title: value.output.title,
|
|
||||||
time: { start: match.state.time.start, end: Date.now() },
|
|
||||||
attachments: value.output.attachments,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
delete ctx.toolcalls[value.toolCallId]
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool-error": {
|
case "tool-error": {
|
||||||
const match = ctx.toolcalls[value.toolCallId]
|
yield* failToolCall(value.toolCallId, value.error)
|
||||||
if (!match || match.state.status !== "running") return
|
|
||||||
yield* session.updatePart({
|
|
||||||
...match,
|
|
||||||
state: {
|
|
||||||
status: "error",
|
|
||||||
input: value.input ?? match.state.input,
|
|
||||||
error: value.error instanceof Error ? value.error.message : String(value.error),
|
|
||||||
time: { start: match.state.time.start, end: Date.now() },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) {
|
|
||||||
ctx.blocked = ctx.shouldBreak
|
|
||||||
}
|
|
||||||
delete ctx.toolcalls[value.toolCallId]
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +435,10 @@ export namespace SessionProcessor {
|
|||||||
},
|
},
|
||||||
{ text: ctx.currentText.text },
|
{ text: ctx.currentText.text },
|
||||||
)).text
|
)).text
|
||||||
ctx.currentText.time = { start: Date.now(), end: Date.now() }
|
{
|
||||||
|
const end = Date.now()
|
||||||
|
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||||
|
}
|
||||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||||
yield* session.updatePart(ctx.currentText)
|
yield* session.updatePart(ctx.currentText)
|
||||||
ctx.currentText = undefined
|
ctx.currentText = undefined
|
||||||
@@ -398,19 +485,30 @@ export namespace SessionProcessor {
|
|||||||
}
|
}
|
||||||
ctx.reasoningMap = {}
|
ctx.reasoningMap = {}
|
||||||
|
|
||||||
const parts = MessageV2.parts(ctx.assistantMessage.id)
|
yield* Effect.forEach(
|
||||||
for (const part of parts) {
|
Object.values(ctx.toolcalls),
|
||||||
if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
|
(call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const toolCallID of Object.keys(ctx.toolcalls)) {
|
||||||
|
const match = yield* readToolCall(toolCallID)
|
||||||
|
if (!match) continue
|
||||||
|
const part = match.part
|
||||||
|
const end = Date.now()
|
||||||
|
const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
|
||||||
yield* session.updatePart({
|
yield* session.updatePart({
|
||||||
...part,
|
...part,
|
||||||
state: {
|
state: {
|
||||||
...part.state,
|
...part.state,
|
||||||
status: "error",
|
status: "error",
|
||||||
error: "Tool execution aborted",
|
error: "Tool execution aborted",
|
||||||
time: { start: Date.now(), end: Date.now() },
|
metadata: { ...metadata, interrupted: true },
|
||||||
|
time: { start: "time" in part.state ? part.state.time.start : end, end },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
ctx.toolcalls = {}
|
||||||
ctx.assistantMessage.time.completed = Date.now()
|
ctx.assistantMessage.time.completed = Date.now()
|
||||||
yield* session.updateMessage(ctx.assistantMessage)
|
yield* session.updateMessage(ctx.assistantMessage)
|
||||||
})
|
})
|
||||||
@@ -486,9 +584,8 @@ export namespace SessionProcessor {
|
|||||||
get message() {
|
get message() {
|
||||||
return ctx.assistantMessage
|
return ctx.assistantMessage
|
||||||
},
|
},
|
||||||
partFromToolCall(toolCallID: string) {
|
updateToolCall,
|
||||||
return ctx.toolcalls[toolCallID]
|
completeToolCall,
|
||||||
},
|
|
||||||
process,
|
process,
|
||||||
} satisfies Handle
|
} satisfies Handle
|
||||||
})
|
})
|
||||||
@@ -497,19 +594,17 @@ export namespace SessionProcessor {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.unwrap(
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
Effect.sync(() =>
|
layer.pipe(
|
||||||
layer.pipe(
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Snapshot.defaultLayer),
|
||||||
Layer.provide(Snapshot.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(LLM.defaultLayer),
|
||||||
Layer.provide(LLM.defaultLayer),
|
Layer.provide(Permission.defaultLayer),
|
||||||
Layer.provide(Permission.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(SessionStatus.defaultLayer),
|
||||||
Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
|
|||||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||||
import { ToolRegistry } from "../tool/registry"
|
import { ToolRegistry } from "../tool/registry"
|
||||||
import { Runner } from "@/effect/runner"
|
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
@@ -48,6 +47,8 @@ import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { TaskTool } from "@/tool/task"
|
import { TaskTool } from "@/tool/task"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { SessionRunState } from "./run-state"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -66,7 +67,6 @@ export namespace SessionPrompt {
|
|||||||
const log = Log.create({ service: "session.prompt" })
|
const log = Log.create({ service: "session.prompt" })
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError>
|
|
||||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
||||||
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
|
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
|
||||||
@@ -89,6 +89,7 @@ export namespace SessionPrompt {
|
|||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const commands = yield* Command.Service
|
const commands = yield* Command.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const fsys = yield* AppFileSystem.Service
|
const fsys = yield* AppFileSystem.Service
|
||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
@@ -99,55 +100,12 @@ export namespace SessionPrompt {
|
|||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
|
const state = yield* SessionRunState.Service
|
||||||
const state = yield* InstanceState.make(
|
const revert = yield* SessionRevert.Service
|
||||||
Effect.fn("SessionPrompt.state")(function* () {
|
|
||||||
const runners = new Map<string, Runner<MessageV2.WithParts>>()
|
|
||||||
yield* Effect.addFinalizer(
|
|
||||||
Effect.fnUntraced(function* () {
|
|
||||||
yield* Effect.forEach(runners.values(), (r) => r.cancel, { concurrency: "unbounded", discard: true })
|
|
||||||
runners.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return { runners }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const getRunner = (runners: Map<string, Runner<MessageV2.WithParts>>, sessionID: SessionID) => {
|
|
||||||
const existing = runners.get(sessionID)
|
|
||||||
if (existing) return existing
|
|
||||||
const runner = Runner.make<MessageV2.WithParts>(scope, {
|
|
||||||
onIdle: Effect.gen(function* () {
|
|
||||||
runners.delete(sessionID)
|
|
||||||
yield* status.set(sessionID, { type: "idle" })
|
|
||||||
}),
|
|
||||||
onBusy: status.set(sessionID, { type: "busy" }),
|
|
||||||
onInterrupt: lastAssistant(sessionID),
|
|
||||||
busy: () => {
|
|
||||||
throw new Session.BusyError(sessionID)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
runners.set(sessionID, runner)
|
|
||||||
return runner
|
|
||||||
}
|
|
||||||
|
|
||||||
const assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError> = Effect.fn(
|
|
||||||
"SessionPrompt.assertNotBusy",
|
|
||||||
)(function* (sessionID: SessionID) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const runner = s.runners.get(sessionID)
|
|
||||||
if (runner?.busy) throw new Session.BusyError(sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
||||||
log.info("cancel", { sessionID })
|
log.info("cancel", { sessionID })
|
||||||
const s = yield* InstanceState.get(state)
|
yield* state.cancel(sessionID)
|
||||||
const runner = s.runners.get(sessionID)
|
|
||||||
if (!runner || !runner.busy) {
|
|
||||||
yield* status.set(sessionID, { type: "idle" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* runner.cancel
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
||||||
@@ -184,6 +142,17 @@ export namespace SessionPrompt {
|
|||||||
return parts
|
return parts
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let prompt!: Interface["prompt"]
|
||||||
|
|
||||||
|
const taskTool = () =>
|
||||||
|
TaskTool.build({
|
||||||
|
agent: agents,
|
||||||
|
config,
|
||||||
|
cancel: SessionPrompt.cancel,
|
||||||
|
resolvePromptParts: SessionPrompt.resolvePromptParts,
|
||||||
|
prompt: SessionPrompt.prompt,
|
||||||
|
})
|
||||||
|
|
||||||
const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
|
const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
|
||||||
session: Session.Info
|
session: Session.Info
|
||||||
history: MessageV2.WithParts[]
|
history: MessageV2.WithParts[]
|
||||||
@@ -388,7 +357,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
model: Provider.Model
|
model: Provider.Model
|
||||||
session: Session.Info
|
session: Session.Info
|
||||||
tools?: Record<string, boolean>
|
tools?: Record<string, boolean>
|
||||||
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall">
|
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
||||||
bypassAgentCheck: boolean
|
bypassAgentCheck: boolean
|
||||||
messages: MessageV2.WithParts[]
|
messages: MessageV2.WithParts[]
|
||||||
}) {
|
}) {
|
||||||
@@ -405,10 +374,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
messages: input.messages,
|
messages: input.messages,
|
||||||
metadata: (val) =>
|
metadata: (val) =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Effect.gen(function* () {
|
input.processor.updateToolCall(options.toolCallId, (match) => {
|
||||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
if (!["running", "pending"].includes(match.state.status)) return match
|
||||||
if (!match || !["running", "pending"].includes(match.state.status)) return
|
return {
|
||||||
yield* sessions.updatePart({
|
|
||||||
...match,
|
...match,
|
||||||
state: {
|
state: {
|
||||||
title: val.title,
|
title: val.title,
|
||||||
@@ -417,7 +385,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
input: args,
|
input: args,
|
||||||
time: { start: Date.now() },
|
time: { start: Date.now() },
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
ask: (req) =>
|
ask: (req) =>
|
||||||
@@ -436,6 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
providerID: input.model.providerID,
|
providerID: input.model.providerID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
})) {
|
})) {
|
||||||
|
const toolDef = item.id === TaskTool.id ? yield* Tool.init(taskTool()) : item
|
||||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||||
tools[item.id] = tool({
|
tools[item.id] = tool({
|
||||||
id: item.id as any,
|
id: item.id as any,
|
||||||
@@ -450,7 +419,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||||
{ args },
|
{ args },
|
||||||
)
|
)
|
||||||
const result = yield* Effect.promise(() => item.execute(args, ctx))
|
const result = yield* Effect.promise(() => toolDef.execute(args, ctx))
|
||||||
const output = {
|
const output = {
|
||||||
...result,
|
...result,
|
||||||
attachments: result.attachments?.map((attachment) => ({
|
attachments: result.attachments?.map((attachment) => ({
|
||||||
@@ -465,6 +434,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
||||||
output,
|
output,
|
||||||
)
|
)
|
||||||
|
if (options.abortSignal?.aborted) {
|
||||||
|
yield* input.processor.completeToolCall(options.toolCallId, output)
|
||||||
|
}
|
||||||
return output
|
return output
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -529,7 +501,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const output = {
|
||||||
title: "",
|
title: "",
|
||||||
metadata,
|
metadata,
|
||||||
output: truncated.content,
|
output: truncated.content,
|
||||||
@@ -541,6 +513,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
})),
|
})),
|
||||||
content: result.content,
|
content: result.content,
|
||||||
}
|
}
|
||||||
|
if (opts.abortSignal?.aborted) {
|
||||||
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
|
}
|
||||||
|
return output
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
tools[key] = item
|
tools[key] = item
|
||||||
@@ -559,7 +535,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}) {
|
}) {
|
||||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const { task: taskTool } = yield* registry.named()
|
|
||||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
@@ -616,8 +591,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}
|
}
|
||||||
|
|
||||||
let error: Error | undefined
|
let error: Error | undefined
|
||||||
|
const taskDef = yield* Tool.init(taskTool())
|
||||||
const result = yield* Effect.promise((signal) =>
|
const result = yield* Effect.promise((signal) =>
|
||||||
taskTool
|
taskDef
|
||||||
.execute(taskArgs, {
|
.execute(taskArgs, {
|
||||||
agent: task.agent,
|
agent: task.agent,
|
||||||
messageID: assistantMessage.id,
|
messageID: assistantMessage.id,
|
||||||
@@ -743,11 +719,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
} satisfies MessageV2.TextPart)
|
} satisfies MessageV2.TextPart)
|
||||||
})
|
})
|
||||||
|
|
||||||
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
|
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* sessions.get(input.sessionID)
|
||||||
if (session.revert) {
|
if (session.revert) {
|
||||||
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
yield* revert.cleanup(session)
|
||||||
}
|
}
|
||||||
const agent = yield* agents.get(input.agent)
|
const agent = yield* agents.get(input.agent)
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
@@ -1305,26 +1281,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
return { info, parts }
|
return { info, parts }
|
||||||
}, Effect.scoped)
|
}, Effect.scoped)
|
||||||
|
|
||||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
|
||||||
function* (input: PromptInput) {
|
const session = yield* sessions.get(input.sessionID)
|
||||||
const session = yield* sessions.get(input.sessionID)
|
yield* revert.cleanup(session)
|
||||||
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
const message = yield* createUserMessage(input)
|
||||||
const message = yield* createUserMessage(input)
|
yield* sessions.touch(input.sessionID)
|
||||||
yield* sessions.touch(input.sessionID)
|
|
||||||
|
|
||||||
const permissions: Permission.Ruleset = []
|
const permissions: Permission.Ruleset = []
|
||||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||||
}
|
}
|
||||||
if (permissions.length > 0) {
|
if (permissions.length > 0) {
|
||||||
session.permission = permissions
|
session.permission = permissions
|
||||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.noReply === true) return message
|
if (input.noReply === true) return message
|
||||||
return yield* loop({ sessionID: input.sessionID })
|
return yield* loop({ sessionID: input.sessionID })
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const lastAssistant = (sessionID: SessionID) =>
|
const lastAssistant = (sessionID: SessionID) =>
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
@@ -1507,7 +1481,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Effect.promise(() => SystemPrompt.skills(agent)),
|
Effect.promise(() => SystemPrompt.skills(agent)),
|
||||||
Effect.promise(() => SystemPrompt.environment(model)),
|
Effect.promise(() => SystemPrompt.environment(model)),
|
||||||
instruction.system().pipe(Effect.orDie),
|
instruction.system().pipe(Effect.orDie),
|
||||||
Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
|
MessageV2.toModelMessagesEffect(msgs, model),
|
||||||
])
|
])
|
||||||
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
||||||
const format = lastUser.format ?? { type: "text" as const }
|
const format = lastUser.format ?? { type: "text" as const }
|
||||||
@@ -1568,16 +1542,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
|
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
|
||||||
"SessionPrompt.loop",
|
"SessionPrompt.loop",
|
||||||
)(function* (input: z.infer<typeof LoopInput>) {
|
)(function* (input: z.infer<typeof LoopInput>) {
|
||||||
const s = yield* InstanceState.get(state)
|
return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
|
||||||
const runner = getRunner(s.runners, input.sessionID)
|
|
||||||
return yield* runner.ensureRunning(runLoop(input.sessionID))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
|
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
|
||||||
function* (input: ShellInput) {
|
function* (input: ShellInput) {
|
||||||
const s = yield* InstanceState.get(state)
|
return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input))
|
||||||
const runner = getRunner(s.runners, input.sessionID)
|
|
||||||
return yield* runner.startShell((signal) => shellImpl(input, signal))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1698,7 +1668,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
assertNotBusy,
|
|
||||||
cancel,
|
cancel,
|
||||||
prompt,
|
prompt,
|
||||||
loop,
|
loop,
|
||||||
@@ -1709,10 +1678,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultLayer = Layer.unwrap(
|
const defaultLayer = Layer.suspend(() =>
|
||||||
Effect.sync(() =>
|
layer
|
||||||
layer.pipe(
|
.pipe(
|
||||||
Layer.provide(SessionStatus.layer),
|
Layer.provide(SessionRunState.defaultLayer),
|
||||||
|
Layer.provide(SessionStatus.defaultLayer),
|
||||||
Layer.provide(SessionCompaction.defaultLayer),
|
Layer.provide(SessionCompaction.defaultLayer),
|
||||||
Layer.provide(SessionProcessor.defaultLayer),
|
Layer.provide(SessionProcessor.defaultLayer),
|
||||||
Layer.provide(Command.defaultLayer),
|
Layer.provide(Command.defaultLayer),
|
||||||
@@ -1721,24 +1691,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
Layer.provide(FileTime.defaultLayer),
|
Layer.provide(FileTime.defaultLayer),
|
||||||
Layer.provide(ToolRegistry.defaultLayer),
|
Layer.provide(ToolRegistry.defaultLayer),
|
||||||
Layer.provide(Truncate.layer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(Agent.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
)
|
||||||
),
|
.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function assertNotBusy(sessionID: SessionID) {
|
|
||||||
return runPromise((svc) => svc.assertNotBusy(SessionID.zod.parse(sessionID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PromptInput = z.object({
|
export const PromptInput = z.object({
|
||||||
sessionID: SessionID.zod,
|
sessionID: SessionID.zod,
|
||||||
messageID: MessageID.zod.optional(),
|
messageID: MessageID.zod.optional(),
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import { Log } from "../util/log"
|
|||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { SessionID, MessageID, PartID } from "./schema"
|
import { SessionID, MessageID, PartID } from "./schema"
|
||||||
import { SessionPrompt } from "./prompt"
|
import { SessionRunState } from "./run-state"
|
||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
|
import { SessionStatus } from "./status"
|
||||||
|
|
||||||
export namespace SessionRevert {
|
export namespace SessionRevert {
|
||||||
const log = Log.create({ service: "session.revert" })
|
const log = Log.create({ service: "session.revert" })
|
||||||
@@ -38,9 +39,10 @@ export namespace SessionRevert {
|
|||||||
const storage = yield* Storage.Service
|
const storage = yield* Storage.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const summary = yield* SessionSummary.Service
|
const summary = yield* SessionSummary.Service
|
||||||
|
const state = yield* SessionRunState.Service
|
||||||
|
|
||||||
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
||||||
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
|
yield* state.assertNotBusy(input.sessionID)
|
||||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||||
let lastUser: MessageV2.User | undefined
|
let lastUser: MessageV2.User | undefined
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* sessions.get(input.sessionID)
|
||||||
@@ -93,7 +95,7 @@ export namespace SessionRevert {
|
|||||||
|
|
||||||
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
||||||
log.info("unreverting", input)
|
log.info("unreverting", input)
|
||||||
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
|
yield* state.assertNotBusy(input.sessionID)
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* sessions.get(input.sessionID)
|
||||||
if (!session.revert) return session
|
if (!session.revert) return session
|
||||||
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
|
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
|
||||||
@@ -148,15 +150,14 @@ export namespace SessionRevert {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.unwrap(
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
Effect.sync(() =>
|
layer.pipe(
|
||||||
layer.pipe(
|
Layer.provide(SessionRunState.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Snapshot.defaultLayer),
|
Layer.provide(Snapshot.defaultLayer),
|
||||||
Layer.provide(Storage.defaultLayer),
|
Layer.provide(Storage.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { Runner } from "@/effect/runner"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Effect, Layer, Scope, ServiceMap } from "effect"
|
||||||
|
import { Session } from "."
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import { SessionID } from "./schema"
|
||||||
|
import { SessionStatus } from "./status"
|
||||||
|
|
||||||
|
export namespace SessionRunState {
|
||||||
|
export interface Interface {
|
||||||
|
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
|
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
|
readonly ensureRunning: (
|
||||||
|
sessionID: SessionID,
|
||||||
|
onInterrupt: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
work: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
) => Effect.Effect<MessageV2.WithParts>
|
||||||
|
readonly startShell: (
|
||||||
|
sessionID: SessionID,
|
||||||
|
onInterrupt: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
work: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
) => Effect.Effect<MessageV2.WithParts>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionRunState") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const status = yield* SessionStatus.Service
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make(
|
||||||
|
Effect.fn("SessionRunState.state")(function* () {
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
|
||||||
|
yield* Effect.addFinalizer(
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
discard: true,
|
||||||
|
})
|
||||||
|
runners.clear()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { runners, scope }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runner = Effect.fn("SessionRunState.runner")(function* (
|
||||||
|
sessionID: SessionID,
|
||||||
|
onInterrupt: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
) {
|
||||||
|
const data = yield* InstanceState.get(state)
|
||||||
|
const existing = data.runners.get(sessionID)
|
||||||
|
if (existing) return existing
|
||||||
|
const next = Runner.make<MessageV2.WithParts>(data.scope, {
|
||||||
|
onIdle: Effect.gen(function* () {
|
||||||
|
data.runners.delete(sessionID)
|
||||||
|
yield* status.set(sessionID, { type: "idle" })
|
||||||
|
}),
|
||||||
|
onBusy: status.set(sessionID, { type: "busy" }),
|
||||||
|
onInterrupt,
|
||||||
|
busy: () => {
|
||||||
|
throw new Session.BusyError(sessionID)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
data.runners.set(sessionID, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
|
||||||
|
const data = yield* InstanceState.get(state)
|
||||||
|
const existing = data.runners.get(sessionID)
|
||||||
|
if (existing?.busy) throw new Session.BusyError(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
|
||||||
|
const data = yield* InstanceState.get(state)
|
||||||
|
const existing = data.runners.get(sessionID)
|
||||||
|
if (!existing || !existing.busy) {
|
||||||
|
yield* status.set(sessionID, { type: "idle" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield* existing.cancel
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
|
||||||
|
sessionID: SessionID,
|
||||||
|
onInterrupt: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
work: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
) {
|
||||||
|
return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
|
||||||
|
})
|
||||||
|
|
||||||
|
const startShell = Effect.fn("SessionRunState.startShell")(function* (
|
||||||
|
sessionID: SessionID,
|
||||||
|
onInterrupt: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
work: Effect.Effect<MessageV2.WithParts>,
|
||||||
|
) {
|
||||||
|
return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function assertNotBusy(sessionID: SessionID) {
|
||||||
|
return runPromise((svc) => svc.assertNotBusy(sessionID))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ export namespace SessionStatus {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function get(sessionID: SessionID) {
|
export async function get(sessionID: SessionID) {
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -85,10 +85,6 @@ export namespace Todo {
|
|||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function update(input: { sessionID: SessionID; todos: Info[] }) {
|
|
||||||
return runPromise((svc) => svc.update(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get(sessionID: SessionID) {
|
export async function get(sessionID: SessionID) {
|
||||||
return runPromise((svc) => svc.get(sessionID))
|
return runPromise((svc) => svc.get(sessionID))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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: {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Effect } from "effect"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -98,23 +97,3 @@ export const SkillTool = Tool.define("skill", async () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const SkillDescription: Tool.DynamicDescription = (agent) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const list = yield* Effect.promise(() => Skill.available(agent))
|
|
||||||
if (list.length === 0) return "No skills are currently available."
|
|
||||||
return [
|
|
||||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
|
||||||
"",
|
|
||||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
|
||||||
"",
|
|
||||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
|
||||||
"",
|
|
||||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
|
||||||
"",
|
|
||||||
"The following skills provide specialized sets of instructions for particular tasks",
|
|
||||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
|
||||||
"",
|
|
||||||
Skill.fmt(list, { verbose: false }),
|
|
||||||
].join("\n")
|
|
||||||
})
|
|
||||||
|
|||||||
+166
-155
@@ -5,9 +5,8 @@ import { Session } from "../session"
|
|||||||
import { SessionID, MessageID } from "../session/schema"
|
import { SessionID, MessageID } from "../session/schema"
|
||||||
import { MessageV2 } from "../session/message-v2"
|
import { MessageV2 } from "../session/message-v2"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { SessionPrompt } from "../session/prompt"
|
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Permission } from "@/permission"
|
import type { SessionPrompt } from "../session/prompt"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
|
||||||
const id = "task"
|
const id = "task"
|
||||||
@@ -25,168 +24,180 @@ const parameters = z.object({
|
|||||||
command: z.string().describe("The command that triggered this task").optional(),
|
command: z.string().describe("The command that triggered this task").optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const TaskTool = Tool.defineEffect(
|
type Metadata = {
|
||||||
id,
|
sessionId: SessionID
|
||||||
Effect.gen(function* () {
|
model: {
|
||||||
const agent = yield* Agent.Service
|
modelID: MessageV2.Assistant["modelID"]
|
||||||
const config = yield* Config.Service
|
providerID: MessageV2.Assistant["providerID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
export type TaskMetadata = Metadata
|
||||||
const cfg = yield* config.get()
|
|
||||||
|
|
||||||
if (!ctx.extra?.bypassAgentCheck) {
|
type Runtime = {
|
||||||
yield* Effect.promise(() =>
|
agent: Agent.Interface
|
||||||
ctx.ask({
|
config: Config.Interface
|
||||||
permission: id,
|
cancel: (sessionID: SessionID) => Promise<void>
|
||||||
patterns: [params.subagent_type],
|
resolvePromptParts: (template: string) => Promise<SessionPrompt.PromptInput["parts"]>
|
||||||
always: ["*"],
|
prompt: (input: SessionPrompt.PromptInput) => Promise<MessageV2.WithParts>
|
||||||
metadata: {
|
}
|
||||||
description: params.description,
|
|
||||||
subagent_type: params.subagent_type,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = yield* agent.get(params.subagent_type)
|
const unbound: Tool.DefWithoutID<typeof parameters, Metadata> = {
|
||||||
if (!next) {
|
description: DESCRIPTION,
|
||||||
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
parameters,
|
||||||
}
|
async execute() {
|
||||||
|
throw new Error("Task tool execution is only available from the prompt runtime")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const canTask = next.permission.some((rule) => rule.permission === id)
|
const build = (runtime: Runtime) => {
|
||||||
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
|
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||||
|
const cfg = yield* runtime.config.get()
|
||||||
|
|
||||||
const taskID = params.task_id
|
if (!ctx.extra?.bypassAgentCheck) {
|
||||||
const session = taskID
|
yield* Effect.promise(() =>
|
||||||
? yield* Effect.promise(() => {
|
ctx.ask({
|
||||||
const id = SessionID.make(taskID)
|
permission: id,
|
||||||
return Session.get(id).catch(() => undefined)
|
patterns: [params.subagent_type],
|
||||||
})
|
always: ["*"],
|
||||||
: undefined
|
metadata: {
|
||||||
const nextSession =
|
description: params.description,
|
||||||
session ??
|
subagent_type: params.subagent_type,
|
||||||
(yield* Effect.promise(() =>
|
},
|
||||||
Session.create({
|
|
||||||
parentID: ctx.sessionID,
|
|
||||||
title: params.description + ` (@${next.name} subagent)`,
|
|
||||||
permission: [
|
|
||||||
...(canTodo
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
permission: "todowrite" as const,
|
|
||||||
pattern: "*" as const,
|
|
||||||
action: "deny" as const,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
...(canTask
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
permission: id,
|
|
||||||
pattern: "*" as const,
|
|
||||||
action: "deny" as const,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
...(cfg.experimental?.primary_tools?.map((item) => ({
|
|
||||||
pattern: "*",
|
|
||||||
action: "allow" as const,
|
|
||||||
permission: item,
|
|
||||||
})) ?? []),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
|
|
||||||
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
|
|
||||||
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
|
|
||||||
|
|
||||||
const model = next.model ?? {
|
|
||||||
modelID: msg.info.modelID,
|
|
||||||
providerID: msg.info.providerID,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.metadata({
|
|
||||||
title: params.description,
|
|
||||||
metadata: {
|
|
||||||
sessionId: nextSession.id,
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const messageID = MessageID.ascending()
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
SessionPrompt.cancel(nextSession.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return yield* Effect.acquireUseRelease(
|
|
||||||
Effect.sync(() => {
|
|
||||||
ctx.abort.addEventListener("abort", cancel)
|
|
||||||
}),
|
}),
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
|
|
||||||
const result = yield* Effect.promise(() =>
|
|
||||||
SessionPrompt.prompt({
|
|
||||||
messageID,
|
|
||||||
sessionID: nextSession.id,
|
|
||||||
model: {
|
|
||||||
modelID: model.modelID,
|
|
||||||
providerID: model.providerID,
|
|
||||||
},
|
|
||||||
agent: next.name,
|
|
||||||
tools: {
|
|
||||||
...(canTodo ? {} : { todowrite: false }),
|
|
||||||
...(canTask ? {} : { task: false }),
|
|
||||||
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
|
||||||
},
|
|
||||||
parts,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: params.description,
|
|
||||||
metadata: {
|
|
||||||
sessionId: nextSession.id,
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
output: [
|
|
||||||
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
|
|
||||||
"",
|
|
||||||
"<task_result>",
|
|
||||||
result.parts.findLast((item) => item.type === "text")?.text ?? "",
|
|
||||||
"</task_result>",
|
|
||||||
].join("\n"),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
ctx.abort.removeEventListener("abort", cancel)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = yield* runtime.agent.get(params.subagent_type)
|
||||||
|
if (!next) {
|
||||||
|
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const canTask = next.permission.some((rule) => rule.permission === id)
|
||||||
|
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
|
||||||
|
|
||||||
|
const taskID = params.task_id
|
||||||
|
const session = taskID
|
||||||
|
? yield* Effect.promise(() => {
|
||||||
|
const id = SessionID.make(taskID)
|
||||||
|
return Session.get(id).catch(() => undefined)
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
const nextSession =
|
||||||
|
session ??
|
||||||
|
(yield* Effect.promise(() =>
|
||||||
|
Session.create({
|
||||||
|
parentID: ctx.sessionID,
|
||||||
|
title: params.description + ` (@${next.name} subagent)`,
|
||||||
|
permission: [
|
||||||
|
...(canTodo
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
permission: "todowrite" as const,
|
||||||
|
pattern: "*" as const,
|
||||||
|
action: "deny" as const,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...(canTask
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
permission: id,
|
||||||
|
pattern: "*" as const,
|
||||||
|
action: "deny" as const,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...(cfg.experimental?.primary_tools?.map((item) => ({
|
||||||
|
pattern: "*",
|
||||||
|
action: "allow" as const,
|
||||||
|
permission: item,
|
||||||
|
})) ?? []),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
|
||||||
|
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
|
||||||
|
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
|
||||||
|
|
||||||
|
const model = next.model ?? {
|
||||||
|
modelID: msg.info.modelID,
|
||||||
|
providerID: msg.info.providerID,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.metadata({
|
||||||
|
title: params.description,
|
||||||
|
metadata: {
|
||||||
|
sessionId: nextSession.id,
|
||||||
|
model,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
const messageID = MessageID.ascending()
|
||||||
description: DESCRIPTION,
|
|
||||||
parameters,
|
|
||||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
|
||||||
return Effect.runPromise(run(params, ctx))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const TaskDescription: Tool.DynamicDescription = (agent) =>
|
function cancel() {
|
||||||
Effect.gen(function* () {
|
return runtime.cancel(nextSession.id)
|
||||||
const items = yield* Effect.promise(() =>
|
}
|
||||||
Agent.list().then((items) => items.filter((item) => item.mode !== "primary")),
|
|
||||||
|
return yield* Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
ctx.abort.addEventListener("abort", cancel)
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const parts = yield* Effect.promise(() => runtime.resolvePromptParts(params.prompt))
|
||||||
|
const result = yield* Effect.promise(() =>
|
||||||
|
runtime.prompt({
|
||||||
|
messageID,
|
||||||
|
sessionID: nextSession.id,
|
||||||
|
model: {
|
||||||
|
modelID: model.modelID,
|
||||||
|
providerID: model.providerID,
|
||||||
|
},
|
||||||
|
agent: next.name,
|
||||||
|
tools: {
|
||||||
|
...(canTodo ? {} : { todowrite: false }),
|
||||||
|
...(canTask ? {} : { task: false }),
|
||||||
|
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
||||||
|
},
|
||||||
|
parts,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: params.description,
|
||||||
|
metadata: {
|
||||||
|
sessionId: nextSession.id,
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
output: [
|
||||||
|
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
|
||||||
|
"",
|
||||||
|
"<task_result>",
|
||||||
|
result.parts.findLast((item) => item.type === "text")?.text ?? "",
|
||||||
|
"</task_result>",
|
||||||
|
].join("\n"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
ctx.abort.removeEventListener("abort", cancel)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const filtered = items.filter((item) => Permission.evaluate(id, item.name, agent.permission).action !== "deny")
|
|
||||||
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
|
|
||||||
const description = list
|
|
||||||
.map(
|
|
||||||
(item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
|
|
||||||
)
|
|
||||||
.join("\n")
|
|
||||||
return ["Available agent types and the tools they have access to:", description].join("\n")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return Tool.define(id, {
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters,
|
||||||
|
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||||
|
return Effect.runPromise(run(params, ctx))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskTool = Object.assign(Effect.succeed(Tool.define(id, unbound)), {
|
||||||
|
id,
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters,
|
||||||
|
build,
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -250,7 +250,7 @@ describe("Runner", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
const runner = Runner.make<string>(s)
|
const runner = Runner.make<string>(s)
|
||||||
const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done"))
|
const result = yield* runner.startShell(Effect.succeed("shell-done"))
|
||||||
expect(result).toBe("shell-done")
|
expect(result).toBe("shell-done")
|
||||||
expect(runner.busy).toBe(false)
|
expect(runner.busy).toBe(false)
|
||||||
}),
|
}),
|
||||||
@@ -264,7 +264,7 @@ describe("Runner", () => {
|
|||||||
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
|
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild)
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
|
|
||||||
const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit)
|
const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit)
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
|
||||||
yield* runner.cancel
|
yield* runner.cancel
|
||||||
@@ -279,12 +279,10 @@ describe("Runner", () => {
|
|||||||
const runner = Runner.make<string>(s)
|
const runner = Runner.make<string>(s)
|
||||||
const gate = yield* Deferred.make<void>()
|
const gate = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const sh = yield* runner
|
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild)
|
||||||
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first")))
|
|
||||||
.pipe(Effect.forkChild)
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
|
|
||||||
const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit)
|
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
|
||||||
yield* Deferred.succeed(gate, undefined)
|
yield* Deferred.succeed(gate, undefined)
|
||||||
@@ -302,37 +300,26 @@ describe("Runner", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const sh = yield* runner
|
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
|
||||||
.startShell((signal) =>
|
|
||||||
Effect.promise(
|
|
||||||
() =>
|
|
||||||
new Promise<string>((resolve) => {
|
|
||||||
signal.addEventListener("abort", () => resolve("aborted"), { once: true })
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.pipe(Effect.forkChild)
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
|
|
||||||
const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit)
|
const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit)
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
|
||||||
yield* runner.cancel
|
yield* runner.cancel
|
||||||
const done = yield* Fiber.await(sh)
|
const done = yield* Fiber.await(sh)
|
||||||
expect(Exit.isSuccess(done)).toBe(true)
|
expect(Exit.isFailure(done)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live(
|
it.live(
|
||||||
"cancel interrupts shell that ignores abort signal",
|
"cancel interrupts shell",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
const runner = Runner.make<string>(s)
|
const runner = Runner.make<string>(s)
|
||||||
const gate = yield* Deferred.make<void>()
|
const gate = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const sh = yield* runner
|
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild)
|
||||||
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ignored")))
|
|
||||||
.pipe(Effect.forkChild)
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
|
|
||||||
const stop = yield* runner.cancel.pipe(Effect.forkChild)
|
const stop = yield* runner.cancel.pipe(Effect.forkChild)
|
||||||
@@ -356,9 +343,7 @@ describe("Runner", () => {
|
|||||||
const runner = Runner.make<string>(s)
|
const runner = Runner.make<string>(s)
|
||||||
const gate = yield* Deferred.make<void>()
|
const gate = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const sh = yield* runner
|
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild)
|
||||||
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result")))
|
|
||||||
.pipe(Effect.forkChild)
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
expect(runner.state._tag).toBe("Shell")
|
expect(runner.state._tag).toBe("Shell")
|
||||||
|
|
||||||
@@ -384,9 +369,7 @@ describe("Runner", () => {
|
|||||||
const calls = yield* Ref.make(0)
|
const calls = yield* Ref.make(0)
|
||||||
const gate = yield* Deferred.make<void>()
|
const gate = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const sh = yield* runner
|
const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild)
|
||||||
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell")))
|
|
||||||
.pipe(Effect.forkChild)
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
|
|
||||||
const work = Effect.gen(function* () {
|
const work = Effect.gen(function* () {
|
||||||
@@ -414,16 +397,7 @@ describe("Runner", () => {
|
|||||||
const runner = Runner.make<string>(s)
|
const runner = Runner.make<string>(s)
|
||||||
const gate = yield* Deferred.make<void>()
|
const gate = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const sh = yield* runner
|
const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild)
|
||||||
.startShell((signal) =>
|
|
||||||
Effect.promise(
|
|
||||||
() =>
|
|
||||||
new Promise<string>((resolve) => {
|
|
||||||
signal.addEventListener("abort", () => resolve("aborted"), { once: true })
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.pipe(Effect.forkChild)
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
|
|
||||||
const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
|
const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild)
|
||||||
@@ -478,7 +452,7 @@ describe("Runner", () => {
|
|||||||
const runner = Runner.make<string>(s, {
|
const runner = Runner.make<string>(s, {
|
||||||
onBusy: Ref.update(count, (n) => n + 1),
|
onBusy: Ref.update(count, (n) => n + 1),
|
||||||
})
|
})
|
||||||
yield* runner.startShell((_signal) => Effect.succeed("done"))
|
yield* runner.startShell(Effect.succeed("done"))
|
||||||
expect(yield* Ref.get(count)).toBe(1)
|
expect(yield* Ref.get(count)).toBe(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -509,9 +483,7 @@ describe("Runner", () => {
|
|||||||
const runner = Runner.make<string>(s)
|
const runner = Runner.make<string>(s)
|
||||||
const gate = yield* Deferred.make<void>()
|
const gate = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const fiber = yield* runner
|
const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild)
|
||||||
.startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok")))
|
|
||||||
.pipe(Effect.forkChild)
|
|
||||||
yield* Effect.sleep("10 millis")
|
yield* Effect.sleep("10 millis")
|
||||||
expect(runner.busy).toBe(true)
|
expect(runner.busy).toBe(true)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture"
|
|||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
|
import { Git } from "../../src/git"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
|
||||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||||
@@ -32,6 +33,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
|
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(watcherConfigLayer),
|
Layer.provide(watcherConfigLayer),
|
||||||
)
|
)
|
||||||
const rt = ManagedRuntime.make(layer)
|
const rt = ManagedRuntime.make(layer)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { describe, expect, spyOn, test } from "bun:test"
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
|
||||||
import * as Lsp from "../../src/lsp/index"
|
import * as Lsp from "../../src/lsp/index"
|
||||||
import * as launch from "../../src/lsp/launch"
|
|
||||||
import { LSPServer } from "../../src/lsp/server"
|
import { LSPServer } from "../../src/lsp/server"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
@@ -54,80 +52,4 @@ describe("lsp.spawn", () => {
|
|||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("spawns builtin Typescript LSP with correct arguments", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
|
|
||||||
// Create dummy tsserver to satisfy Module.resolve
|
|
||||||
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
|
|
||||||
await fs.mkdir(tsdk, { recursive: true })
|
|
||||||
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
|
|
||||||
|
|
||||||
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
stdin: {},
|
|
||||||
stdout: {},
|
|
||||||
stderr: {},
|
|
||||||
on: () => {},
|
|
||||||
kill: () => {},
|
|
||||||
}) as any,
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await LSPServer.Typescript.spawn(tmp.path)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(spawnSpy).toHaveBeenCalled()
|
|
||||||
const args = spawnSpy.mock.calls[0][1] as string[]
|
|
||||||
|
|
||||||
expect(args).toContain("--tsserver-path")
|
|
||||||
expect(args).toContain("--tsserver-log-verbosity")
|
|
||||||
expect(args).toContain("off")
|
|
||||||
} finally {
|
|
||||||
spawnSpy.mockRestore()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
|
|
||||||
// Create dummy tsserver to satisfy Module.resolve
|
|
||||||
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
|
|
||||||
await fs.mkdir(tsdk, { recursive: true })
|
|
||||||
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
|
|
||||||
|
|
||||||
// NO tsconfig.json or jsconfig.json created here
|
|
||||||
|
|
||||||
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
stdin: {},
|
|
||||||
stdout: {},
|
|
||||||
stderr: {},
|
|
||||||
on: () => {},
|
|
||||||
kill: () => {},
|
|
||||||
}) as any,
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await LSPServer.Typescript.spawn(tmp.path)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(spawnSpy).toHaveBeenCalled()
|
|
||||||
const args = spawnSpy.mock.calls[0][1] as string[]
|
|
||||||
|
|
||||||
expect(args).toContain("--ignore-node-modules")
|
|
||||||
} finally {
|
|
||||||
spawnSpy.mockRestore()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect, describe, afterEach } from "bun:test"
|
||||||
|
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
|
||||||
|
import { parseRedirectUri } from "../../src/mcp/oauth-provider"
|
||||||
|
|
||||||
|
describe("parseRedirectUri", () => {
|
||||||
|
test("returns defaults when no URI provided", () => {
|
||||||
|
const result = parseRedirectUri()
|
||||||
|
expect(result.port).toBe(19876)
|
||||||
|
expect(result.path).toBe("/mcp/oauth/callback")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses port and path from URI", () => {
|
||||||
|
const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
|
||||||
|
expect(result.port).toBe(8080)
|
||||||
|
expect(result.path).toBe("/oauth/callback")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns defaults for invalid URI", () => {
|
||||||
|
const result = parseRedirectUri("not-a-valid-url")
|
||||||
|
expect(result.port).toBe(19876)
|
||||||
|
expect(result.path).toBe("/mcp/oauth/callback")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("McpOAuthCallback.ensureRunning", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await McpOAuthCallback.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("starts server with custom redirectUri port and path", async () => {
|
||||||
|
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
|
||||||
|
expect(McpOAuthCallback.isRunning()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
@@ -30,7 +32,11 @@ describe("memory: abort controller leak", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const tool = await WebFetchTool.init()
|
const tool = await WebFetchTool.pipe(
|
||||||
|
Effect.flatMap((info) => Effect.promise(() => info.init())),
|
||||||
|
Effect.provide(FetchHttpClient.layer),
|
||||||
|
Effect.runPromise,
|
||||||
|
)
|
||||||
|
|
||||||
// Warm up
|
// Warm up
|
||||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture"
|
|||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Plugin } from "../../src/plugin/index"
|
import { Plugin } from "../../src/plugin/index"
|
||||||
|
import { ModelsDev } from "../../src/provider/models"
|
||||||
import { Provider } from "../../src/provider/provider"
|
import { Provider } from "../../src/provider/provider"
|
||||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
@@ -1823,6 +1824,73 @@ test("custom model inherits api.url from models.dev provider", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("mode cost preserves over-200k pricing from base model", () => {
|
||||||
|
const provider = {
|
||||||
|
id: "openai",
|
||||||
|
name: "OpenAI",
|
||||||
|
env: [],
|
||||||
|
api: "https://api.openai.com/v1",
|
||||||
|
models: {
|
||||||
|
"gpt-5.4": {
|
||||||
|
id: "gpt-5.4",
|
||||||
|
name: "GPT-5.4",
|
||||||
|
family: "gpt",
|
||||||
|
release_date: "2026-03-05",
|
||||||
|
attachment: true,
|
||||||
|
reasoning: true,
|
||||||
|
temperature: false,
|
||||||
|
tool_call: true,
|
||||||
|
cost: {
|
||||||
|
input: 2.5,
|
||||||
|
output: 15,
|
||||||
|
cache_read: 0.25,
|
||||||
|
context_over_200k: {
|
||||||
|
input: 5,
|
||||||
|
output: 22.5,
|
||||||
|
cache_read: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 1_050_000,
|
||||||
|
input: 922_000,
|
||||||
|
output: 128_000,
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
modes: {
|
||||||
|
fast: {
|
||||||
|
cost: {
|
||||||
|
input: 5,
|
||||||
|
output: 30,
|
||||||
|
cache_read: 0.5,
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
body: {
|
||||||
|
service_tier: "priority",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ModelsDev.Provider
|
||||||
|
|
||||||
|
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
|
||||||
|
expect(model.cost.input).toEqual(5)
|
||||||
|
expect(model.cost.output).toEqual(30)
|
||||||
|
expect(model.cost.cache.read).toEqual(0.5)
|
||||||
|
expect(model.cost.cache.write).toEqual(0)
|
||||||
|
expect(model.options["serviceTier"]).toEqual("priority")
|
||||||
|
expect(model.cost.experimentalOver200K).toEqual({
|
||||||
|
input: 5,
|
||||||
|
output: 22.5,
|
||||||
|
cache: {
|
||||||
|
read: 0.5,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("model variants are generated for reasoning models", async () => {
|
test("model variants are generated for reasoning models", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user