Compare commits

..
Author SHA1 Message Date
Dax Raad 3eeeec359a chore: extract misc fixes into #18328 2026-03-19 22:13:38 -04:00
Dax Raad 65e786258a chore: extract OAuth changes into #18327 2026-03-19 21:48:23 -04:00
Dax Raad cbc40a5981 chore: extract node entry point into #18324 2026-03-19 21:30:35 -04:00
Dax Raad b9b210a864 Merge branch 'dev' into opencode-2-0 2026-03-19 21:22:28 -04:00
Dax Raad d473b7e971 chore: extract which/global changes into #18320 2026-03-19 21:22:06 -04:00
Dax Raad f5783c4313 chore: extract portable process changes into #18318 2026-03-19 21:15:31 -04:00
Dax Raad 9439a5647e chore: revert drizzle upgrade (extracted to sqlite PR) 2026-03-19 21:10:53 -04:00
Dax Raad 2bfe81ee5c chore: extract SQLite abstraction into separate PR (#refactor/sqlite-abstraction) 2026-03-19 21:02:58 -04:00
Dax Raad fcf1bb010c chore: update lockfile and package.json 2026-03-19 20:53:04 -04:00
Dax Raad 08b6d9c6dc sync 2026-03-19 20:48:44 -04:00
Dax Raad 0293a8bb80 chore: revert changes overlapping with #18308 2026-03-19 20:48:23 -04:00
Dax Raad 850dbb93eb Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 19:33:19 -04:00
Dax Raad 48e867ee20 Merge remote-tracking branch 'origin/dev' into opencode-2-0
# Conflicts:
#	.opencode/tool/github-pr-search.ts
#	.opencode/tool/github-triage.ts
2026-03-19 19:03:48 -04:00
Dax Raad b5ebc541b9 Merge remote-tracking branch 'origin/dev' into opencode-2-0 2026-03-19 18:52:18 -04:00
Dax Raad bd7a4cec90 sync 2026-03-19 17:53:35 -04:00
Dax Raad 63af295a17 Merge origin/dev into opencode-2-0 2026-03-19 16:07:13 -04:00
Dax Raad 04954a9620 Merge remote-tracking branch 'origin/opencode-2-0' into opencode-2-0 2026-03-11 14:32:38 -04:00
Dax Raad fb63fd79a3 cleanup 2026-03-11 14:29:35 -04:00
Dax Raad 2e04b66eab sync 2026-03-11 14:29:03 -04:00
Dax Raad f0b7c8c374 refactor(npm): inline pkgPath and lockPath variables 2026-03-11 14:29:03 -04:00
Dax Raad be6f59035a unbreak 2026-03-11 14:29:03 -04:00
Dax Raad 27ab51f490 sync 2026-03-11 14:29:03 -04:00
Dax Raad bca723e8fe core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-11 14:29:03 -04:00
Dax Raad 1ac39718d8 sync 2026-03-11 14:29:03 -04:00
Dax Raad 190319fb56 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-11 14:29:03 -04:00
Dax Raad 3154f0a61c core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-11 14:29:03 -04:00
Dax Raad 0b686b8178 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-11 14:29:03 -04:00
Dax Raad 4cba56171b sync 2026-03-11 14:29:03 -04:00
Dax Raad 66342acd31 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-11 14:29:03 -04:00
Dax Raad 88dae67549 refactor(server): replace Bun serve with Hono node adapters 2026-03-11 14:29:03 -04:00
Dax Raad 0ec42582f3 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-11 14:29:02 -04:00
Luke Parker 4f82248a68 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-11 14:29:02 -04:00
Dax Raad 5e069aab97 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-11 14:29:02 -04:00
Dax Raad 5325b2ec99 core: fix custom tool loading to properly resolve module paths 2026-03-11 14:29:02 -04:00
Dax Raad 2a98920922 sync 2026-03-11 14:29:02 -04:00
Dax Raad 5ea92ea6cb sync 2026-03-11 14:29:02 -04:00
Dax Raad a18528a7ee sync 2026-03-11 14:29:02 -04:00
Dax Raad ced125a974 core: log npm install errors to console for debugging dependency failures 2026-03-11 14:29:02 -04:00
Dax Raad 655fe20beb sync 2026-03-11 14:29:02 -04:00
Dax Raad dd0c258e23 core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-11 14:29:02 -04:00
Dax Raad 791e27d289 sync 2026-03-11 14:29:02 -04:00
Dax Raad fac0aec69f tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-11 14:29:02 -04:00
Dax Raad ca26e639f6 core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad 0b5d54f2cb core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-11 14:29:02 -04:00
Dax Raad 1b408cf06b core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-11 14:29:02 -04:00
Dax Raad 8e102d19ed core: disable npm bin links to fix package installation in sandboxed environments 2026-03-11 14:29:02 -04:00
Dax Raad 721b2406e9 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-11 14:29:01 -04:00
Dax Raad 4a6a18cd79 sync 2026-03-11 14:28:37 -04:00
Daxandgreptile-apps[bot] c10b5880cc Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Daxandgreptile-apps[bot] e6bf83084c Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:28:37 -04:00
Dax Raad 6722ee22ee sync 2026-03-11 14:28:36 -04:00
Dax Raad 870a5731ac refactor: lsp server and core improvements 2026-03-11 14:28:24 -04:00
Luke Parker 7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax 6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad 040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad 4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad 4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad 21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad 5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad 070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad 406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad 5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker 2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad 3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad 2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad 58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad 0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad 58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad 0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad 124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad 85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad 528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad 0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Daxandgreptile-apps[bot] 27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Daxandgreptile-apps[bot] 173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad 656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
174 changed files with 4326 additions and 6474 deletions
+1 -3
View File
@@ -10,7 +10,6 @@
adamdotdevin adamdotdevin
-agusbasari29 AI PR slop -agusbasari29 AI PR slop
ariane-emory ariane-emory
-danieljoshuanazareth
edemaine edemaine
-florianleibert -florianleibert
fwang fwang
@@ -18,9 +17,8 @@ iamdavidhill
jayair jayair
kitlangton kitlangton
kommander kommander
-opencode2026
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-danieljoshuanazareth -OpenCode2026
+5 -21
View File
@@ -50,17 +50,20 @@ jobs:
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
needs: unit
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
settings: settings:
- name: linux - name: linux
host: blacksmith-4vcpu-ubuntu-2404 host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows - name: windows
host: blacksmith-4vcpu-windows-2025 host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
env: env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers PLAYWRIGHT_BROWSERS_PATH: 0
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -73,28 +76,9 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers - name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app working-directory: packages/app
run: bunx playwright install chromium run: ${{ matrix.settings.playwright }}
- name: Run app e2e tests - name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
+351 -318
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-WXQ4b1hHFk2vDWz41fJmj+z0twee6r0YR0JGH0iw0ZI=", "x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
"aarch64-linux": "sha256-YIdnfkHGLfUq3cZkycvL7DQ8BvC5X+VDia7UTLgJBx8=", "aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
"aarch64-darwin": "sha256-bMUeI1LcBYgKBwG92WazTgxNryZF2Gv9iQgK46Pd+3A=", "aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
"x86_64-darwin": "sha256-fJbEd1j8ObZ2OMykYVU6v0uI1gy2eoCFIZ9ovuiNeLY=" "x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
} }
} }
+1 -3
View File
@@ -112,8 +112,6 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
} }
} }
+7 -145
View File
@@ -9,7 +9,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
projectCloseMenuSelector, projectCloseMenuSelector,
projectWorkspacesToggleSelector, projectWorkspacesToggleSelector,
@@ -24,16 +23,6 @@ import {
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) { export async function defocus(page: Page) {
await page await page
.evaluate(() => { .evaluate(() => {
@@ -207,49 +196,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
} }
export async function isSidebarClosed(page: Page) { export async function isSidebarClosed(page: Page) {
const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true"
}
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first() const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first() await expect(button).toBeVisible()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 }) return (await button.getAttribute("aria-expanded")) !== "true"
await assertHealthy(page, context)
return button
} }
export async function toggleSidebar(page: Page) { export async function toggleSidebar(page: Page) {
@@ -260,7 +209,7 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) { export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return if (!(await isSidebarClosed(page))) return
const button = await waitSidebarButton(page, "openSidebar") const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click() await button.click()
const opened = await expect(button) const opened = await expect(button)
@@ -277,7 +226,7 @@ export async function openSidebar(page: Page) {
export async function closeSidebar(page: Page) { export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return if (await isSidebarClosed(page)) return
const button = await waitSidebarButton(page, "closeSidebar") const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click() await button.click()
const closed = await expect(button) const closed = await expect(button)
@@ -292,7 +241,6 @@ export async function closeSidebar(page: Page) {
} }
export async function openSettings(page: Page) { export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page) await defocus(page)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
@@ -305,8 +253,6 @@ export async function openSettings(page: Page) {
if (opened) return dialog if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click() await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
return dialog return dialog
@@ -368,12 +314,10 @@ export async function seedProjects(page: Page, input: { directory: string; extra
export async function createTestProject() { export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`) await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" }) execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
@@ -395,24 +339,12 @@ export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
} }
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) { export async function waitSlug(page: Page, skip: string[] = []) {
let prev = "" let prev = ""
let next = "" let next = ""
await expect await expect
.poll( .poll(
async () => { () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
if (skip.includes(slug)) return "" if (skip.includes(slug)) return ""
@@ -442,7 +374,6 @@ export async function waitDir(page: Page, directory: string) {
await expect await expect
.poll( .poll(
async () => { async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
return resolveSlug(slug) return resolveSlug(slug)
@@ -455,69 +386,6 @@ export async function waitDir(page: Page, directory: string) {
return { directory: target, slug: base64Encode(target) } return { directory: target, slug: base64Encode(target) }
} }
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
const target = await resolveDirectory(input.directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory)
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) { export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url) const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1] return match?.[1]
@@ -929,14 +797,8 @@ export async function openStatusPopover(page: Page) {
} }
export async function openProjectMenu(page: Page, projectSlug: string) { export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1) await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page const menu = page
.locator(dropdownMenuContentSelector) .locator(dropdownMenuContentSelector)
@@ -945,7 +807,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger const clicked = await trigger
.click({ force: true, timeout: 1500 }) .click({ timeout: 1500 })
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
+5 -39
View File
@@ -1,16 +1,7 @@
import { test as base, expect, type Page } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal" import type { E2EWindow } from "../src/testing/terminal"
import { import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
healthPhase, import { promptSelector } from "./selectors"
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
@@ -36,29 +27,6 @@ type WorkerFixtures = {
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [ directory: [
async ({}, use) => { async ({}, use) => {
const directory = await getWorktree() const directory = await getWorktree()
@@ -80,20 +48,21 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID }) await expect(page.locator(promptSelector)).toBeVisible()
} }
await use(gotoSession) await use(gotoSession)
}, },
withProject: async ({ page }, use) => { withProject: async ({ page }, use) => {
await use(async (callback, options) => { await use(async (callback, options) => {
const root = await createTestProject() const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>() const sessions = new Map<string, string>()
const dirs = new Set<string>() const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra }) await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID)) await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID }) await expect(page.locator(promptSelector)).toBeVisible()
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (current) trackSession(current) if (current) trackSession(current)
} }
@@ -108,16 +77,13 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
try { try {
await gotoSession() await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally { } finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled( await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })), Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
) )
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root) await cleanupTestProject(root)
setHealthPhase(page, "test")
} }
}) })
}, },
@@ -1,4 +1,5 @@
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import {
defocus, defocus,
@@ -6,14 +7,43 @@ import {
cleanupTestProject, cleanupTestProject,
openSidebar, openSidebar,
sessionIDFromUrl, sessionIDFromUrl,
setWorkspacesEnabled, waitDir,
waitSession,
waitSessionSaved,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
@@ -54,7 +84,9 @@ test("switching back to a project opens the latest workspace session", async ({
await withProject( await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => { async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page) await defocus(page)
await setWorkspacesEnabled(page, slug, true) await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
@@ -76,7 +108,8 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible() await expect(btn).toBeVisible()
await btn.click({ force: true }) await btn.click({ force: true })
await waitSession(page, { directory: space }) await waitSlug(page)
await waitDir(page, space)
// Create a session by sending a prompt // Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
@@ -90,7 +123,6 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url()) const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space) trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -98,14 +130,15 @@ test("switching back to a project opens the latest workspace session", async ({
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click({ force: true }) await otherButton.click()
await waitSession(page, { directory: other }) await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const rootButton = page.locator(projectSwitchSelector(slug)).first() const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible() await expect(rootButton).toBeVisible()
await rootButton.click({ force: true }) await rootButton.click()
await waitSession(page, { directory: space, sessionID: created }) await waitDir(page, space)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
}, },
{ extra: [other] }, { extra: [other] },
@@ -1,15 +1,6 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
@@ -23,7 +14,20 @@ function button(space: { slug: string; raw: string }) {
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) { async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page) await openSidebar(page)
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 }) await expect
.poll(
async () => {
const row = page.locator(item(space)).first()
try {
await row.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
} }
async function createWorkspace(page: Page, root: string, seen: string[]) { async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -45,8 +49,7 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
await expect(next).toBeVisible() await expect(next).toBeVisible()
await next.click({ force: true }) await next.click({ force: true })
await waitSession(page, { directory: space.directory }) return waitDir(page, space.directory)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
} }
async function createSessionFromWorkspace( async function createSessionFromWorkspace(
@@ -54,28 +57,39 @@ async function createSessionFromWorkspace(
space: { slug: string; raw: string; directory: string }, space: { slug: string; raw: string; directory: string },
text: string, text: string,
) { ) {
await openWorkspaceNewSession(page, space) const next = await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text) await prompt.fill(text)
await page.keyboard.press("Enter") await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await waitDir(page, next.directory)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next.slug }
}
await waitSessionSaved(space.directory, sessionID) async function sessionDirectory(directory: string, sessionID: string) {
await createSdk(space.directory) const info = await createSdk(directory)
.session.abort({ sessionID }) .session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
return sessionID if (!info) return ""
return info.directory
} }
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: root, trackDirectory, trackSession }) => { await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, root, true) await setWorkspacesEnabled(page, root, true)
@@ -87,8 +101,17 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
trackDirectory(second.directory) trackDirectory(second.directory)
await waitWorkspaceReady(page, second) await waitWorkspaceReady(page, second)
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory) trackSession(firstSession.sessionID, first.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
}) })
}) })
@@ -1,14 +1,6 @@
import type { Locator, Page } from "@playwright/test" import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import { import {
promptAgentSelector, promptAgentSelector,
promptModelSelector, promptModelSelector,
@@ -37,6 +29,8 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null) const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> { async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => { return page.evaluate(() => {
const win = window as Window & { const win = window as Window & {
@@ -50,6 +44,21 @@ async function probe(page: Page): Promise<Probe | null> {
}) })
} }
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> { async function read(page: Page): Promise<Footer> {
return { return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()), agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -178,7 +187,8 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
async function goto(page: Page, directory: string, sessionID?: string) { async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID }) await expect(page.locator(promptSelector)).toBeVisible()
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
} }
async function submit(page: Page, value: string) { async function submit(page: Page, value: string) {
@@ -214,7 +224,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen])) const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitSession(page, { directory: next.directory }) await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
return next return next
} }
@@ -246,7 +256,9 @@ async function newWorkspaceSession(page: Page, slug: string) {
await button.click({ force: true }) await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page)) const next = await resolveSlug(await waitSlug(page))
return waitSession(page, { directory: next.directory }).then((item) => item.directory) await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
} }
test("session model and variant restore per session without leaking into new sessions", async ({ test("session model and variant restore per session without leaking into new sessions", async ({
@@ -265,7 +277,7 @@ test("session model and variant restore per session without leaking into new ses
await waitUser(directory, first) await waitUser(directory, first)
await page.reload() await page.reload()
await waitSession(page, { directory, sessionID: first }) await expect(page.locator(promptSelector)).toBeVisible()
await waitFooter(page, firstState) await waitFooter(page, firstState)
await gotoSession() await gotoSession()
@@ -169,70 +169,6 @@ async function overflow(page: Parameters<typeof test>[0]["page"], file: string)
} }
} }
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => { test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000) test.setTimeout(180_000)
@@ -282,56 +218,6 @@ test("review applies inline comment clicks without horizontal overflow", async (
}) })
}) })
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.") test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
-1
View File
@@ -54,7 +54,6 @@
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
+5 -13
View File
@@ -9,7 +9,6 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect" import { type Duration, Effect } from "effect"
import { import {
type Component, type Component,
@@ -82,11 +81,6 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider> return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
} }
function QueryProvider(props: ParentProps) {
const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function AppShellProviders(props: ParentProps) { function AppShellProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <SettingsProvider>
@@ -142,13 +136,11 @@ export function AppBaseProviders(props: ParentProps) {
<LanguageProvider> <LanguageProvider>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider> <DialogProvider>
<DialogProvider> <MarkedProviderWithNativeParser>
<MarkedProviderWithNativeParser> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider> </MarkedProviderWithNativeParser>
</MarkedProviderWithNativeParser> </DialogProvider>
</DialogProvider>
</QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
</LanguageProvider> </LanguageProvider>
@@ -12,9 +12,10 @@ import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
export function DialogConnectProvider(props: { provider: string }) { export function DialogConnectProvider(props: { provider: string }) {
@@ -34,6 +34,7 @@ export type FormState = {
apiKey: string apiKey: string
models: ModelRow[] models: ModelRow[]
headers: HeaderRow[] headers: HeaderRow[]
saving: boolean
err: { err: {
providerID?: string providerID?: string
name?: string name?: string
@@ -16,6 +16,7 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} }, { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} }, { row: "h1", key: "", value: "", err: {} },
], ],
saving: false,
err: {}, err: {},
}, },
t, t,
@@ -59,6 +60,7 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: "Authorization", value: "one", err: {} }, { row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} }, { row: "h1", key: "authorization", value: "two", err: {} },
], ],
saving: false,
err: {}, err: {},
}, },
t, t,
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
@@ -32,6 +31,7 @@ export function DialogCustomProvider(props: Props) {
apiKey: "", apiKey: "",
models: [modelRow()], models: [modelRow()],
headers: [headerRow()], headers: [headerRow()],
saving: false,
err: {}, err: {},
}) })
@@ -116,49 +116,48 @@ export function DialogCustomProvider(props: Props) {
return output.result return output.result
} }
const saveMutation = useMutation(() => ({ const save = async (e: SubmitEvent) => {
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { e.preventDefault()
const disabledProviders = globalSync.data.config.disabled_providers ?? [] if (form.saving) return
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) { const result = validate()
await globalSDK.client.auth.set({ if (!result) return
setForm("saving", true)
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID, providerID: result.providerID,
auth: { auth: {
type: "api", type: "api",
key: result.key, key: result.key,
}, },
}) })
} : Promise.resolve()
await globalSync.updateConfig({ auth
provider: { [result.providerID]: result.config }, .then(() =>
disabled_providers: nextDisabled, globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
)
.then(() => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
})
}) })
return result .catch((err: unknown) => {
}, const message = err instanceof Error ? err.message : String(err)
onSuccess: (result) => { showToast({ title: language.t("common.requestFailed"), description: message })
dialog.close() })
showToast({ .finally(() => {
variant: "success", setForm("saving", false)
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
}) })
},
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
},
}))
const save = (e: SubmitEvent) => {
e.preventDefault()
if (saveMutation.isPending) return
const result = validate()
if (!result) return
saveMutation.mutate(result)
} }
return ( return (
@@ -313,14 +312,8 @@ export function DialogCustomProvider(props: Props) {
</Button> </Button>
</div> </div>
<Button <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
class="w-auto self-start" {form.saving ? language.t("common.saving") : language.t("common.submit")}
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
</Button> </Button>
</form> </form>
</div> </div>
@@ -2,7 +2,6 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -29,6 +28,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
color: props.project.icon?.color || "pink", color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "", iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "", startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false, dragOver: false,
iconHover: false, iconHover: false,
}) })
@@ -71,37 +71,38 @@ export function DialogEditProject(props: { project: LocalProject }) {
setStore("iconUrl", "") setStore("iconUrl", "")
} }
const saveMutation = useMutation(() => ({ async function handleSubmit(e: SubmitEvent) {
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate() await Promise.resolve()
.then(async () => {
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
dialog.close()
})
.finally(() => {
setStore("saving", false)
})
} }
return ( return (
@@ -245,8 +246,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}> <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}> <Button type="submit" variant="primary" size="large" disabled={store.saving}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")} {store.saving ? language.t("common.saving") : language.t("common.save")}
</Button> </Button>
</div> </div>
</form> </form>
@@ -1,5 +1,4 @@
import { useMutation } from "@tanstack/solid-query" import { Component, createMemo, createSignal, Show } from "solid-js"
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -18,6 +17,7 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const [loading, setLoading] = createSignal<string | null>(null)
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {}) Object.entries(sync.data.mcp ?? {})
@@ -25,8 +25,10 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
const toggle = useMutation(() => ({ const toggle = async (name: string) => {
mutationFn: async (name: string) => { if (loading()) return
setLoading(name)
try {
const status = sync.data.mcp[name] const status = sync.data.mcp[name]
if (status?.status === "connected") { if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name }) await sdk.client.mcp.disconnect({ name })
@@ -36,8 +38,10 @@ export const DialogSelectMcp: Component = () => {
const result = await sdk.client.mcp.status() const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data) if (result.data) sync.set("mcp", result.data)
}, } finally {
})) setLoading(null)
}
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length) const totalCount = createMemo(() => items().length)
@@ -55,8 +59,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]} filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => { onSelect={(x) => {
if (!x || toggle.isPending) return if (x) toggle(x.name)
toggle.mutate(x.name)
}} }}
> >
{(i) => { {(i) => {
@@ -80,7 +83,7 @@ export const DialogSelectMcp: Component = () => {
<Show when={statusLabel()}> <Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span> <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show> </Show>
<Show when={toggle.isPending && toggle.variables === i.name}> <Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
</Show> </Show>
</div> </div>
@@ -89,14 +92,7 @@ export const DialogSelectMcp: Component = () => {
</Show> </Show>
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch <Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
</div> </div>
</div> </div>
) )
@@ -6,7 +6,6 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@@ -187,6 +186,7 @@ export function DialogSelectServer() {
name: "", name: "",
username: DEFAULT_USERNAME, username: DEFAULT_USERNAME,
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
@@ -198,6 +198,7 @@ export function DialogSelectServer() {
username: "", username: "",
password: "", password: "",
error: "", error: "",
busy: false,
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
}, },
}) })
@@ -208,6 +209,7 @@ export function DialogSelectServer() {
name: "", name: "",
username: DEFAULT_USERNAME, username: DEFAULT_USERNAME,
password: "", password: "",
adding: false,
error: "", error: "",
showForm: false, showForm: false,
status: undefined, status: undefined,
@@ -222,78 +224,10 @@ export function DialogSelectServer() {
password: "", password: "",
error: "", error: "",
status: undefined, status: undefined,
busy: false,
}) })
} }
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key const active = server.key
const newConn = server.add(next) const newConn = server.add(next)
@@ -362,7 +296,7 @@ export function DialogSelectServer() {
} }
const handleAddChange = (value: string) => { const handleAddChange = (value: string) => {
if (addMutation.isPending) return if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" }) setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) => void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -370,12 +304,12 @@ export function DialogSelectServer() {
} }
const handleAddNameChange = (value: string) => { const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return if (store.addServer.adding) return
setStore("addServer", { name: value, error: "" }) setStore("addServer", { name: value, error: "" })
} }
const handleAddUsernameChange = (value: string) => { const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return if (store.addServer.adding) return
setStore("addServer", { username: value, error: "" }) setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) => void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -383,7 +317,7 @@ export function DialogSelectServer() {
} }
const handleAddPasswordChange = (value: string) => { const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return if (store.addServer.adding) return
setStore("addServer", { password: value, error: "" }) setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) => void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }), setStore("addServer", { status: next }),
@@ -391,7 +325,7 @@ export function DialogSelectServer() {
} }
const handleEditChange = (value: string) => { const handleEditChange = (value: string) => {
if (editMutation.isPending) return if (store.editServer.busy) return
setStore("editServer", { value, error: "" }) setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) => void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -399,12 +333,12 @@ export function DialogSelectServer() {
} }
const handleEditNameChange = (value: string) => { const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return if (store.editServer.busy) return
setStore("editServer", { name: value, error: "" }) setStore("editServer", { name: value, error: "" })
} }
const handleEditUsernameChange = (value: string) => { const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return if (store.editServer.busy) return
setStore("editServer", { username: value, error: "" }) setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) => void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
@@ -412,13 +346,85 @@ export function DialogSelectServer() {
} }
const handleEditPasswordChange = (value: string) => { const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return if (store.editServer.busy) return
setStore("editServer", { password: value, error: "" }) setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) => void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }), setStore("editServer", { status: next }),
) )
} }
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
resetEdit()
}
const mode = createMemo<"list" | "add" | "edit">(() => { const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit" if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add" if (store.addServer.showForm) return "add"
@@ -458,26 +464,23 @@ export function DialogSelectServer() {
password: conn.http.password ?? "", password: conn.http.password ?? "",
error: "", error: "",
status: store.status[ServerConnection.key(conn)]?.healthy, status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
}) })
} }
const submitForm = () => { const submitForm = () => {
if (mode() === "add") { if (mode() === "add") {
if (addMutation.isPending) return void handleAdd(store.addServer.url)
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return return
} }
const original = editing() const original = editing()
if (!original) return if (!original) return
if (editMutation.isPending) return void handleEdit(original, store.editServer.value)
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
} }
const isFormMode = createMemo(() => mode() !== "list") const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add") const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
const formTitle = createMemo(() => { const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title") if (!isFormMode()) return language.t("dialog.server.title")
+2 -7
View File
@@ -1383,16 +1383,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")} accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden" class="hidden"
onChange={(e) => { onChange={(e) => {
const list = e.currentTarget.files const file = e.currentTarget.files?.[0]
if (list) { if (file) void addAttachment(file)
for (const file of Array.from(list)) {
void addAttachment(file)
}
}
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
@@ -1,6 +1,4 @@
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker" export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export { ACCEPTED_FILE_TYPES }
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([ const IMAGE_EXTS = new Map([
@@ -20,6 +18,61 @@ const TEXT_MIMES = new Set([
"application/yaml", "application/yaml",
]) ])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096 const SAMPLE = 4096
function kind(type: string) { function kind(type: string) {
+31 -27
View File
@@ -4,7 +4,6 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -131,30 +130,41 @@ const useDefaultServerKey = (
} }
} }
const useMcpToggleMutation = () => { const useMcpToggle = (input: {
const sync = useSync() sync: ReturnType<typeof useSync>
const sdk = useSDK() sdk: ReturnType<typeof useSDK>
const language = useLanguage() language: ReturnType<typeof useLanguage>
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
return useMutation(() => ({ const toggle = async (name: string) => {
mutationFn: async (name: string) => { if (loading()) return
const status = sync.data.mcp[name] setLoading(name)
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
const result = await sdk.client.mcp.status() try {
if (result.data) sync.set("mcp", result.data) const status = input.sync.data.mcp[name]
}, await (status?.status === "connected"
onError: (err) => { ? input.sdk.client.mcp.disconnect({ name })
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({ showToast({
variant: "error", variant: "error",
title: language.t("common.requestFailed"), title: input.language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err), description: err instanceof Error ? err.message : String(err),
}) })
}, } finally {
})) setLoading(null)
}
}
return { loading, toggle }
} }
export function StatusPopover() { export function StatusPopover() {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
@@ -171,7 +181,7 @@ export function StatusPopover() {
}) })
const health = useServerHealth(servers) const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation() const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -327,11 +337,8 @@ export function StatusPopover() {
<button <button
type="button" type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => { onClick={() => mcp.toggle(name)}
if (toggleMcp.isPending) return disabled={mcp.loading() === name}
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
> >
<div <div
classList={{ classList={{
@@ -347,11 +354,8 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}> <div onClick={(event) => event.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name} disabled={mcp.loading() === name}
onChange={() => { onChange={() => mcp.toggle(name)}
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
/> />
</div> </div>
</button> </button>
-89
View File
@@ -1,89 +0,0 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const MIME_EXT = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["application/pdf", "pdf"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
new Set(
ACCEPTED_FILE_TYPES.flatMap((item) => {
if (item.startsWith(".")) return [item.slice(1)]
if (item === "text/*") return TEXT_EXT
const out = MIME_EXT.get(item)
return out ? [out] : []
}),
),
).sort()
export function filePickerFilters(ext?: string[]) {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
-1
View File
@@ -378,7 +378,6 @@ function createGlobalSync() {
return globalStore.error return globalStore.error
}, },
child: children.child, child: children.child,
peek: children.peek,
bootstrap, bootstrap,
updateConfig, updateConfig,
project: projectApi, project: projectApi,
@@ -226,15 +226,6 @@ export function createChildStoreManager(input: {
return childStore return childStore
} }
function peek(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) { function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory) const cached = metaCache.get(directory)
@@ -265,7 +256,6 @@ export function createChildStoreManager(input: {
children, children,
ensureChild, ensureChild,
child, child,
peek,
projectMeta, projectMeta,
projectIcon, projectIcon,
mark, mark,
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] } type OpenFilePickerOptions = { title?: string; multiple?: boolean }
type SaveFilePickerOptions = { title?: string; defaultPath?: string } type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string } type UpdateInfo = { updateAvailable: boolean; version?: string }
-2
View File
@@ -23,8 +23,6 @@ export const dict = {
"command.sidebar.toggle": "Toggle sidebar", "command.sidebar.toggle": "Toggle sidebar",
"command.project.open": "Open project", "command.project.open": "Open project",
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.provider.connect": "Connect provider", "command.provider.connect": "Connect provider",
"command.server.switch": "Switch server", "command.server.switch": "Switch server",
"command.settings.open": "Open settings", "command.settings.open": "Open settings",
-1
View File
@@ -1,5 +1,4 @@
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server" export { ServerConnection } from "./context/server"
+1 -9
View File
@@ -1,12 +1,11 @@
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Component, Show, onMount } from "solid-js" import { Component, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import type { E2EWindow } from "@/testing/terminal"
export type InitError = { export type InitError = {
name: string name: string
@@ -227,13 +226,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
actionError: undefined as string | undefined, actionError: undefined as string | undefined,
}) })
onMount(() => {
const win = window as E2EWindow
if (!win.__opencode_e2e) return
const detail = formatError(props.error, language.t)
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
})
async function checkForUpdates() { async function checkForUpdates() {
if (!platform.checkUpdate) return if (!platform.checkUpdate) return
setStore("checking", true) setStore("checking", true)
+20 -61
View File
@@ -129,16 +129,6 @@ export default function Layout(props: ParentProps) {
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
return {
slug,
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = { const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -147,7 +137,7 @@ export default function Layout(props: ParentProps) {
dark: "theme.scheme.dark", dark: "theme.scheme.dark",
} }
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => route().dir) const currentDir = createMemo(() => decode64(params.dir) ?? "")
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory, autoselect: !initialDirectory,
@@ -494,8 +484,8 @@ export default function Layout(props: ParentProps) {
} }
const currentSession = params.id const currentSession = params.id
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return if (directory === currentDir() && props.sessionID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return if (directory === currentDir() && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey) dismissSessionAlert(sessionKey)
@@ -630,7 +620,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir() const activeDir = currentDir()
return workspaceIds(project).filter((directory) => { return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = workspaceKey(directory) === workspaceKey(activeDir) const active = directory === activeDir
return expanded || active return expanded || active
}) })
}) })
@@ -697,7 +687,7 @@ export default function Layout(props: ParentProps) {
seen: lru, seen: lru,
keep: sessionID, keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR, limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined, preserve: directory === params.dir && params.id ? [params.id] : undefined,
}) })
} }
@@ -710,7 +700,7 @@ export default function Layout(props: ParentProps) {
}) })
createEffect(() => { createEffect(() => {
route() params.dir
globalSDK.url globalSDK.url
prefetchToken.value += 1 prefetchToken.value += 1
@@ -936,26 +926,6 @@ export default function Layout(props: ParentProps) {
navigateToSession(session) navigateToSession(session)
} }
function navigateProjectByOffset(offset: number) {
const projects = layout.projects.list()
if (projects.length === 0) return
const current = currentProject()?.worktree
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
const active = current ?? fallback
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
const target =
index === -1
? offset > 0
? projects[0]
: projects[projects.length - 1]
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
openProject(target.worktree)
}
function navigateSessionByUnseen(offset: number) { function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions() const sessions = currentSessions()
if (sessions.length === 0) return if (sessions.length === 0) return
@@ -1022,20 +992,6 @@ export default function Layout(props: ParentProps) {
keybind: "mod+o", keybind: "mod+o",
onSelect: () => chooseProject(), onSelect: () => chooseProject(),
}, },
{
id: "project.previous",
title: language.t("command.project.previous"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowup",
onSelect: () => navigateProjectByOffset(-1),
},
{
id: "project.next",
title: language.t("command.project.next"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowdown",
onSelect: () => navigateProjectByOffset(1),
},
{ {
id: "provider.connect", id: "provider.connect",
title: language.t("command.provider.connect"), title: language.t("command.provider.connect"),
@@ -1736,10 +1692,13 @@ export default function Layout(props: ParentProps) {
createEffect( createEffect(
on( on(
() => { () => {
return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const const dir = params.dir
const directory = dir ? decode64(dir) : undefined
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
}, },
([ready, slug, id, root, dir]) => { ([ready, dir, id, root, directory, resolved]) => {
if (!ready || !slug || !dir) { if (!ready || !dir || !directory) {
activeRoute.session = "" activeRoute.session = ""
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
activeRoute.directory = "" activeRoute.directory = ""
@@ -1753,28 +1712,29 @@ export default function Layout(props: ParentProps) {
return return
} }
const session = `${slug}/${id}` const next = resolved || directory
const session = `${dir}/${id}`
if (!root) { if (!root) {
activeRoute.session = session activeRoute.session = session
activeRoute.directory = dir activeRoute.directory = next
activeRoute.sessionProject = "" activeRoute.sessionProject = ""
return return
} }
if (server.projects.last() !== root) server.projects.touch(root) if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || dir !== activeRoute.directory const changed = session !== activeRoute.session || next !== activeRoute.directory
if (changed) { if (changed) {
activeRoute.session = session activeRoute.session = session
activeRoute.directory = dir activeRoute.directory = next
activeRoute.sessionProject = syncSessionRoute(dir, id, root) activeRoute.sessionProject = syncSessionRoute(next, id, root)
return return
} }
if (root === activeRoute.sessionProject) return if (root === activeRoute.sessionProject) return
activeRoute.directory = dir activeRoute.directory = next
activeRoute.sessionProject = rememberSessionRoute(dir, id, root) activeRoute.sessionProject = rememberSessionRoute(next, id, root)
}, },
), ),
) )
@@ -1967,7 +1927,6 @@ export default function Layout(props: ParentProps) {
const projectSidebarCtx: ProjectSidebarContext = { const projectSidebarCtx: ProjectSidebarContext = {
currentDir, currentDir,
currentProject,
sidebarOpened: () => layout.sidebar.opened(), sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering, sidebarHovering,
hoverProject: () => state.hoverProject, hoverProject: () => state.hoverProject,
+2 -2
View File
@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
stores.flatMap(roots).sort(sortSessions(now))[0] stores.flatMap(roots).sort(sortSessions(now))[0]
export function hasProjectPermissions<T>( export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined> | undefined, request: Record<string, T[] | undefined>,
include: (item: T) => boolean = () => true, include: (item: T) => boolean = () => true,
) { ) {
return Object.values(request ?? {}).some((list) => list?.some(include)) return Object.values(request).some((list) => list?.some(include))
} }
export const childMapByParent = (sessions: Session[] | undefined) => { export const childMapByParent = (sessions: Session[] | undefined) => {
@@ -15,7 +15,6 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = { export type ProjectSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
currentProject: Accessor<LocalProject | undefined>
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined> hoverProject: Accessor<string | undefined>
@@ -279,7 +278,11 @@ export const SortableProject = (props: {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
const sortable = createSortable(props.project.worktree) const sortable = createSortable(props.project.worktree)
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree) const selected = createMemo(
() =>
props.project.worktree === props.ctx.currentDir() ||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers" import { childMapByParent, sortedRootSessions } from "./helpers"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
id: string id: string
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session)) const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree) const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const active = createMemo(() => props.ctx.currentDir() === props.directory)
const workspaceValue = createMemo(() => { const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory) const name = branch ?? getFilename(props.directory)
+121 -129
View File
@@ -1,6 +1,5 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { import {
batch, batch,
onCleanup, onCleanup,
@@ -328,7 +327,10 @@ export default function Page() {
}) })
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
restoring: undefined as string | undefined,
reverting: false,
reviewSnap: false, reviewSnap: false,
scrollGesture: 0, scrollGesture: 0,
scroll: { scroll: {
@@ -504,6 +506,7 @@ export default function Page() {
const [followup, setFollowup] = createStore({ const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>, items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
sending: {} as Record<string, string | undefined>,
failed: {} as Record<string, string | undefined>, failed: {} as Record<string, string | undefined>,
paused: {} as Record<string, boolean | undefined>, paused: {} as Record<string, boolean | undefined>,
edit: {} as Record< edit: {} as Record<
@@ -641,24 +644,25 @@ export default function Page() {
globalSync.set("project", [...list, next]) globalSync.set("project", [...list, next])
} }
const gitMutation = useMutation(() => ({
mutationFn: () => sdk.client.project.initGit(),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
},
}))
function initGit() { function initGit() {
if (gitMutation.isPending) return if (ui.git) return
gitMutation.mutate() setUi("git", true)
void sdk.client.project
.initGit()
.then((x) => {
if (!x.data) return
upsert(x.data)
})
.catch((err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
})
.finally(() => {
setUi("git", false)
})
} }
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
@@ -957,8 +961,8 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")} {language.t("session.review.noVcs.createGit.description")}
</div> </div>
</div> </div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}> <Button size="large" disabled={ui.git} onClick={initGit}>
{gitMutation.isPending {ui.git
? language.t("session.review.noVcs.createGit.actionLoading") ? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")} : language.t("session.review.noVcs.createGit.action")}
</Button> </Button>
@@ -1375,40 +1379,10 @@ export default function Page() {
return followup.edit[id] return followup.edit[id]
}) })
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
if (input.manual) setFollowup("paused", input.sessionID, undefined)
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
return false
})
if (!ok) return
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
if (input.manual) resumeScroll()
},
}))
const followupBusy = (sessionID: string) =>
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
const sendingFollowup = createMemo(() => { const sendingFollowup = createMemo(() => {
const id = params.id const id = params.id
if (!id) return if (!id) return
if (!followupBusy(id)) return return followup.sending[id]
return followupMutation.variables?.id
}) })
const queueEnabled = createMemo(() => { const queueEnabled = createMemo(() => {
@@ -1448,15 +1422,37 @@ export default function Page() {
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve() if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve() if (followup.sending[sessionID]) return Promise.resolve()
return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual }) if (opts?.manual) setFollowup("paused", sessionID, undefined)
setFollowup("sending", sessionID, id)
setFollowup("failed", sessionID, undefined)
return sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
})
.then((ok) => {
if (ok === false) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
if (opts?.manual) resumeScroll()
})
.catch((err) => {
setFollowup("failed", sessionID, id)
fail(err)
})
.finally(() => {
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
})
} }
const editFollowup = (id: string) => { const editFollowup = (id: string) => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
if (followupBusy(sessionID)) return if (followup.sending[sessionID]) return
const item = queuedFollowups().find((entry) => entry.id === id) const item = queuedFollowups().find((entry) => entry.id === id)
if (!item) return if (!item) return
@@ -1479,74 +1475,6 @@ export default function Page() {
const halt = (sessionID: string) => const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
await halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const restoreMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const sessionID = params.id
if (!sessionID) return
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
await task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
},
}))
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
const fork = (input: { sessionID: string; messageID: string }) => { const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID) const value = draft(input.messageID)
const dir = base64Encode(sdk.directory) const dir = base64Encode(sdk.directory)
@@ -1568,13 +1496,77 @@ export default function Page() {
} }
const revert = (input: { sessionID: string; messageID: string }) => { const revert = (input: { sessionID: string; messageID: string }) => {
if (reverting()) return if (ui.reverting || ui.restoring) return
return revertMutation.mutateAsync(input) const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
setUi("reverting", true)
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
return halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
setUi("reverting", false)
})
} }
const restore = (id: string) => { const restore = (id: string) => {
if (!params.id || reverting()) return const sessionID = params.id
return restoreMutation.mutateAsync(id) if (!sessionID || ui.restoring || ui.reverting) return
const next = userMessages().find((item) => item.id > id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
setUi("restoring", id)
setUi("reverting", true)
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
return task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
batch(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
setUi("reverting", false)
})
})
} }
const rolled = createMemo(() => { const rolled = createMemo(() => {
@@ -1593,7 +1585,7 @@ export default function Page() {
const item = queuedFollowups()[0] const item = queuedFollowups()[0]
if (!item) return if (!item) return
if (followupBusy(sessionID)) return if (followup.sending[sessionID]) return
if (followup.failed[sessionID] === item.id) return if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return if (followup.paused[sessionID]) return
if (composer.blocked()) return if (composer.blocked()) return
@@ -1788,8 +1780,8 @@ export default function Page() {
rolled().length > 0 rolled().length > 0
? { ? {
items: rolled(), items: rolled(),
restoring: restoring(), restoring: ui.restoring,
disabled: reverting(), disabled: ui.reverting,
onRestore: restore, onRestore: restore,
} }
: undefined : undefined
@@ -1,6 +1,5 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -25,6 +24,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]), custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]), customOn: cached?.customOn ?? ([] as boolean[]),
editing: false, editing: false,
sending: false,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
@@ -126,40 +126,36 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
} }
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
},
onError: fail,
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
},
onError: fail,
}))
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
const reply = async (answers: QuestionAnswer[]) => { const reply = async (answers: QuestionAnswer[]) => {
if (sending()) return if (store.sending) return
await replyMutation.mutateAsync(answers)
props.onSubmit()
setStore("sending", true)
try {
await sdk.client.question.reply({ requestID: props.request.id, answers })
replied = true
cache.delete(props.request.id)
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
} }
const reject = async () => { const reject = async () => {
if (sending()) return if (store.sending) return
await rejectMutation.mutateAsync()
props.onSubmit()
setStore("sending", true)
try {
await sdk.client.question.reject({ requestID: props.request.id })
replied = true
cache.delete(props.request.id)
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
} }
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
@@ -179,7 +175,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const customToggle = () => { const customToggle = () => {
if (sending()) return if (store.sending) return
if (!multi()) { if (!multi()) {
setStore("customOn", store.tab, true) setStore("customOn", store.tab, true)
@@ -202,14 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const customOpen = () => { const customOpen = () => {
if (sending()) return if (store.sending) return
if (!on()) setStore("customOn", store.tab, true) if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true) setStore("editing", true)
customUpdate(input(), true) customUpdate(input(), true)
} }
const selectOption = (optIndex: number) => { const selectOption = (optIndex: number) => {
if (sending()) return if (store.sending) return
if (optIndex === options().length) { if (optIndex === options().length) {
customOpen() customOpen()
@@ -231,7 +227,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const next = () => { const next = () => {
if (sending()) return if (store.sending) return
if (store.editing) commitCustom() if (store.editing) commitCustom()
if (store.tab >= total() - 1) { if (store.tab >= total() - 1) {
@@ -244,14 +240,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
const back = () => { const back = () => {
if (sending()) return if (store.sending) return
if (store.tab <= 0) return if (store.tab <= 0) return
setStore("tab", store.tab - 1) setStore("tab", store.tab - 1)
setStore("editing", false) setStore("editing", false)
} }
const jump = (tab: number) => { const jump = (tab: number) => {
if (sending()) return if (store.sending) return
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
} }
@@ -274,7 +270,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.answers[i()]?.length ?? 0) > 0 || (store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
} }
disabled={sending()} disabled={store.sending}
onClick={() => jump(i())} onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/> />
@@ -285,16 +281,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
footer={ footer={
<> <>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}> <Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
{language.t("ui.common.dismiss")} {language.t("ui.common.dismiss")}
</Button> </Button>
<div data-slot="question-footer-actions"> <div data-slot="question-footer-actions">
<Show when={store.tab > 0}> <Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={sending()} onClick={back}> <Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
{language.t("ui.common.back")} {language.t("ui.common.back")}
</Button> </Button>
</Show> </Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}> <Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")} {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button> </Button>
</div> </div>
@@ -315,7 +311,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={picked()} data-picked={picked()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={picked()} aria-checked={picked()}
disabled={sending()} disabled={store.sending}
onClick={() => selectOption(i())} onClick={() => selectOption(i())}
> >
<span data-slot="question-option-check" aria-hidden="true"> <span data-slot="question-option-check" aria-hidden="true">
@@ -349,7 +345,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
data-picked={on()} data-picked={on()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
disabled={sending()} disabled={store.sending}
onClick={customOpen} onClick={customOpen}
> >
<span <span
@@ -381,7 +377,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
onMouseDown={(e) => { onMouseDown={(e) => {
if (sending()) { if (store.sending) {
e.preventDefault() e.preventDefault()
return return
} }
@@ -423,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
placeholder={language.t("ui.question.custom.placeholder")} placeholder={language.t("ui.question.custom.placeholder")}
value={input()} value={input()}
rows={1} rows={1}
disabled={sending()} disabled={store.sending}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
@@ -217,6 +217,17 @@ export function FileTabContent(props: { tab: string }) {
onDelete={controls.remove} onDelete={controls.remove}
/> />
), ),
onDraftPopoverFocusOut: (e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
}
}, 0)
},
}) })
createEffect(() => { createEffect(() => {
@@ -415,6 +426,7 @@ export function FileTabContent(props: { tab: string }) {
commentsUi.onLineSelectionEnd(range) commentsUi.onLineSelectionEnd(range)
}} }}
search={search} search={search}
overflow="scroll"
class="select-text" class="select-text"
media={{ media={{
mode: "auto", mode: "auto",
@@ -1,7 +1,6 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -322,6 +321,7 @@ export function MessageTimeline(props: {
const [title, setTitle] = createStore({ const [title, setTitle] = createStore({
draft: "", draft: "",
editing: false, editing: false,
saving: false,
menuOpen: false, menuOpen: false,
pendingRename: false, pendingRename: false,
pendingShare: false, pendingShare: false,
@@ -335,6 +335,38 @@ export function MessageTimeline(props: {
let more: HTMLButtonElement | undefined let more: HTMLButtonElement | undefined
const [req, setReq] = createStore({ share: false, unshare: false })
const shareSession = () => {
const id = sessionID()
if (!id || req.share) return
if (!shareEnabled()) return
setReq("share", true)
globalSDK.client.session
.share({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to share session", err)
})
.finally(() => {
setReq("share", false)
})
}
const unshareSession = () => {
const id = sessionID()
if (!id || req.unshare) return
if (!shareEnabled()) return
setReq("unshare", true)
globalSDK.client.session
.unshare({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to unshare session", err)
})
.finally(() => {
setReq("unshare", false)
})
}
const viewShare = () => { const viewShare = () => {
const url = shareUrl() const url = shareUrl()
if (!url) return if (!url) return
@@ -350,54 +382,6 @@ export function MessageTimeline(props: {
return language.t("common.requestFailed") return language.t("common.requestFailed")
} }
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk.client.session.update({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === input.id)
if (index !== -1) draft.session[index].title = input.title
}),
)
setTitle("editing", false)
},
onError: (err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
},
}))
const shareSession = () => {
const id = sessionID()
if (!id || shareMutation.isPending) return
if (!shareEnabled()) return
shareMutation.mutate(id)
}
const unshareSession = () => {
const id = sessionID()
if (!id || unshareMutation.isPending) return
if (!shareEnabled()) return
unshareMutation.mutate(id)
}
createEffect( createEffect(
on( on(
sessionKey, sessionKey,
@@ -405,6 +389,7 @@ export function MessageTimeline(props: {
setTitle({ setTitle({
draft: "", draft: "",
editing: false, editing: false,
saving: false,
menuOpen: false, menuOpen: false,
pendingRename: false, pendingRename: false,
pendingShare: false, pendingShare: false,
@@ -423,22 +408,40 @@ export function MessageTimeline(props: {
} }
const closeTitleEditor = () => { const closeTitleEditor = () => {
if (titleMutation.isPending) return if (title.saving) return
setTitle("editing", false) setTitle({ editing: false, saving: false })
} }
const saveTitleEditor = () => { const saveTitleEditor = async () => {
const id = sessionID() const id = sessionID()
if (!id) return if (!id) return
if (titleMutation.isPending) return if (title.saving) return
const next = title.draft.trim() const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) { if (!next || next === (titleValue() ?? "")) {
setTitle("editing", false) setTitle({ editing: false, saving: false })
return return
} }
titleMutation.mutate({ id, title: next }) setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
} }
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
@@ -709,7 +712,7 @@ export function MessageTimeline(props: {
titleRef = el titleRef = el
}} }}
value={title.draft} value={title.draft}
disabled={titleMutation.isPending} disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)} onInput={(event) => setTitle("draft", event.currentTarget.value)}
@@ -860,9 +863,9 @@ export function MessageTimeline(props: {
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={shareSession} onClick={shareSession}
disabled={shareMutation.isPending} disabled={req.share}
> >
{shareMutation.isPending {req.share
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</Button> </Button>
@@ -883,9 +886,9 @@ export function MessageTimeline(props: {
variant="secondary" variant="secondary"
class="w-full shadow-none border border-border-weak-base" class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession} onClick={unshareSession}
disabled={unshareMutation.isPending} disabled={req.unshare}
> >
{unshareMutation.isPending {req.unshare
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</Button> </Button>
@@ -894,7 +897,7 @@ export function MessageTimeline(props: {
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={viewShare} onClick={viewShare}
disabled={unshareMutation.isPending} disabled={req.unshare}
> >
{language.t("session.share.action.view")} {language.t("session.share.action.view")}
</Button> </Button>
@@ -24,13 +24,7 @@ import {
FreeUsageLimitError, FreeUsageLimitError,
SubscriptionUsageLimitError, SubscriptionUsageLimitError,
} from "./error" } from "./error"
import { import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
buildCostChunk,
createBodyConverter,
createStreamPartConverter,
createResponseConverter,
UsageInfo,
} from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic" import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google" import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai" import { openaiHelper } from "./provider/openai"
@@ -96,7 +90,7 @@ export async function handler(
const projectId = input.request.headers.get("x-opencode-project") ?? "" const projectId = input.request.headers.get("x-opencode-project") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? "" const ocClient = input.request.headers.get("x-opencode-client") ?? ""
logger.metric({ logger.metric({
is_stream: isStream, is_tream: isStream,
session: sessionId, session: sessionId,
request: requestId, request: requestId,
client: ocClient, client: ocClient,
@@ -236,7 +230,7 @@ export async function handler(
const body = JSON.stringify( const body = JSON.stringify(
responseConverter({ responseConverter({
...json, ...json,
cost: calculateOccurredCost(billingSource, costInfo), cost: calculateOccuredCost(billingSource, costInfo),
}), }),
) )
logger.metric({ response_length: body.length }) logger.metric({ response_length: body.length })
@@ -280,8 +274,8 @@ export async function handler(
await trialLimiter?.track(usageInfo) await trialLimiter?.track(usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo) const cost = calculateOccuredCost(billingSource, costInfo)
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost))) c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
} }
c.close() c.close()
return return
@@ -824,7 +818,7 @@ export async function handler(
} }
} }
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) { function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0" return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
} }
@@ -20,7 +20,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:") const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
const isBedrockModelID = providerModel.startsWith("global.anthropic.") const isBedrockModelID = providerModel.startsWith("global.anthropic.")
const isBedrock = isBedrockModelArn || isBedrockModelID const isBedrock = isBedrockModelArn || isBedrockModelID
const isDatabricks = providerModel.startsWith("databricks-claude-")
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6") const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
return { return {
format: "anthropic", format: "anthropic",
@@ -29,7 +28,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}` ? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
: providerApi + "/messages", : providerApi + "/messages",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
if (isBedrock || isDatabricks) { if (isBedrock) {
headers.set("Authorization", `Bearer ${apiKey}`) headers.set("Authorization", `Bearer ${apiKey}`)
} else { } else {
headers.set("x-api-key", apiKey) headers.set("x-api-key", apiKey)
@@ -48,14 +47,9 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
model: undefined, model: undefined,
stream: undefined, stream: undefined,
} }
: isDatabricks : {
? { service_tier: "standard_only",
anthropic_version: "bedrock-2023-05-31", }),
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
}
: {
service_tier: "standard_only",
}),
}), }),
createBinaryStreamDecoder: () => { createBinaryStreamDecoder: () => {
if (!isBedrock) return undefined if (!isBedrock) return undefined
@@ -173,6 +167,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
} }
}, },
retrieve: () => usage, retrieve: () => usage,
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
} }
}, },
normalizeUsage: (usage: Usage) => ({ normalizeUsage: (usage: Usage) => ({
@@ -56,6 +56,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
usage = json.usageMetadata usage = json.usageMetadata
}, },
retrieve: () => usage, retrieve: () => usage,
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
} }
}, },
normalizeUsage: (usage: Usage) => { normalizeUsage: (usage: Usage) => {
@@ -54,6 +54,7 @@ export const oaCompatHelper: ProviderHelper = () => ({
usage = json.usage usage = json.usage
}, },
retrieve: () => usage, retrieve: () => usage,
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
} }
}, },
normalizeUsage: (usage: Usage) => { normalizeUsage: (usage: Usage) => {
@@ -44,6 +44,7 @@ export const openaiHelper: ProviderHelper = () => ({
usage = json.response.usage usage = json.response.usage
}, },
retrieve: () => usage, retrieve: () => usage,
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
} }
}, },
normalizeUsage: (usage: Usage) => { normalizeUsage: (usage: Usage) => {
@@ -43,6 +43,7 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
createUsageParser: () => { createUsageParser: () => {
parse: (chunk: string) => void parse: (chunk: string) => void
retrieve: () => any retrieve: () => any
buidlCostChunk: (cost: string) => string
} }
normalizeUsage: (usage: any) => UsageInfo normalizeUsage: (usage: any) => UsageInfo
} }
@@ -161,19 +162,6 @@ export interface CommonChunk {
} }
} }
export function buildCostChunk(format: ZenData.Format, cost: string): string {
switch (format) {
case "anthropic":
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
case "openai":
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
case "oa-compat":
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
default:
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
}
}
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) { export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
return (body: any): any => { return (body: any): any => {
if (from === to) return body if (from === to) return body
+1 -10
View File
@@ -6,11 +6,6 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme,
import { getStore } from "./store" import { getStore } from "./store"
import { setTitlebar } from "./windows" import { setTitlebar } from "./windows"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}
type Deps = { type Deps = {
killSidecar: () => void killSidecar: () => void
installCli: () => Promise<string> installCli: () => Promise<string>
@@ -99,15 +94,11 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle( ipcMain.handle(
"open-file-picker", "open-file-picker",
async ( async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
_event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
) => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
title: opts?.title ?? "Choose a file", title: opts?.title ?? "Choose a file",
defaultPath: opts?.defaultPath, defaultPath: opts?.defaultPath,
filters: pickerFilters(opts?.extensions),
}) })
if (result.canceled) return null if (result.canceled) return null
return opts?.multiple ? result.filePaths : result.filePaths[0] return opts?.multiple ? result.filePaths : result.filePaths[0]
@@ -50,8 +50,6 @@ export type ElectronAPI = {
multiple?: boolean multiple?: boolean
title?: string title?: string
defaultPath?: string defaultPath?: string
accept?: string[]
extensions?: string[]
}) => Promise<string | string[] | null> }) => Promise<string | string[] | null>
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null> saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void openLink: (url: string) => void
@@ -1,8 +1,6 @@
// @refresh reload // @refresh reload
import { import {
ACCEPTED_FILE_EXTENSIONS,
ACCEPTED_FILE_TYPES,
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
@@ -113,8 +111,6 @@ const createPlatform = (): Platform => {
const result = await window.api.openFilePicker({ const result = await window.api.openFilePicker({
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"), title: opts?.title ?? t("desktop.dialog.chooseFile"),
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
}) })
return handleWslPicker(result) return handleWslPicker(result)
}, },
-3
View File
@@ -1,8 +1,6 @@
// @refresh reload // @refresh reload
import { import {
ACCEPTED_FILE_EXTENSIONS,
filePickerFilters,
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
@@ -100,7 +98,6 @@ const createPlatform = (): Platform => {
directory: false, directory: false,
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"), title: opts?.title ?? t("desktop.dialog.chooseFile"),
filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS),
}) })
return handleWslPicker(result) return handleWslPicker(result)
}, },
+4 -2
View File
@@ -90,6 +90,10 @@
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
@@ -121,7 +125,6 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -132,7 +135,6 @@
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"remeda": "catalog:", "remeda": "catalog:",
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bun
import fs from "fs"
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)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")
-13
View File
@@ -199,19 +199,6 @@ for (const item of targets) {
}, },
}) })
// Smoke test: only run if binary is for current platform
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
const binaryPath = `dist/${name}/bin/opencode`
console.log(`Running smoke test: ${binaryPath} --version`)
try {
const versionOutput = await $`${binaryPath} --version`.text()
console.log(`Smoke test passed: ${versionOutput.trim()}`)
} catch (e) {
console.error(`Smoke test failed for ${name}:`, e)
process.exit(1)
}
}
await $`rm -rf ./dist/${name}/bin/tui` await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write( await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify( JSON.stringify(
+2
View File
@@ -11,6 +11,7 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance") const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap") const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config") const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session") const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema") const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
@@ -54,6 +55,7 @@ const seed = async () => {
}) })
} finally { } finally {
await Instance.disposeAll().catch(() => {}) await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
} }
} }
+54 -53
View File
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
## Choose scope ## Choose scope
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal. Use the shared runtime for process-wide services with one lifecycle for the whole app.
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
- Global services (no per-directory state): Account, Auth, Installation, Truncate - Shared runtime: config readers, stateless helpers, global clients
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth - Instance-scoped: watchers, per-project caches, session state, project-bound background work
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`. Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
## Service shape ## Service shape
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions: For a fully migrated module, use the public namespace directly:
```ts ```ts
export namespace Foo { export namespace Foo {
@@ -28,52 +28,53 @@ export namespace Foo {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
// For instance-scoped services: return Service.of({
const state = yield* InstanceState.make<State>( get: Effect.fn("Foo.get")(function* (id) {
Effect.fn("Foo.state")(() => Effect.succeed({ ... })), return yield* ...
) }),
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
}) })
return Service.of({ get })
}), }),
) )
// Optional: wire dependencies export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const runPromise = makeRunPromise(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
} }
``` ```
Rules: Rules:
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split - Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
- `runPromise` goes inside the namespace (not exported unless tests need it) - Export `defaultLayer` only when wiring dependencies is useful
- Facade functions are plain `async function` — no `fn()` wrappers - Use the direct namespace form once the module is fully migrated
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation
## Schema → Zod interop ## Temporary mixed-mode pattern
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: Prefer a single namespace whenever possible.
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
```ts ```ts
import { zod } from "@/util/effect-zod" export namespace FooEffect {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
}
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(...)
}
``` ```
See `Auth.ZodInfo` for the canonical example. Then keep the old boundary thin:
```ts
export namespace Foo {
export function get(id: FooID) {
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
}
}
```
Remove the `Effect` suffix when the boundary split is gone.
## Scheduled Tasks ## Scheduled Tasks
@@ -106,23 +107,22 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist ## Migration checklist
Fully migrated (single namespace, InstanceState where needed, flattened facade): Done now:
- [x] `Account``account/index.ts` - [x] `AccountEffect` (mixed-mode)
- [x] `Auth``auth/index.ts` (uses `zod()` helper for Schema→Zod interop) - [x] `AuthEffect` (mixed-mode)
- [x] `File``file/index.ts` - [x] `TruncateEffect` (mixed-mode)
- [x] `FileTime``file/time.ts` - [x] `Question`
- [x] `FileWatcher``file/watcher.ts` - [x] `PermissionNext`
- [x] `Format``format/index.ts` - [x] `ProviderAuth`
- [x] `Installation``installation/index.ts` - [x] `FileWatcher`
- [x] `Permission``permission/index.ts` - [x] `FileTime`
- [x] `ProviderAuth``provider/auth.ts` - [x] `Format`
- [x] `Question``question/index.ts` - [x] `Vcs`
- [x] `Skill``skill/index.ts` - [x] `Skill`
- [x] `Snapshot``snapshot/index.ts` - [x] `Discovery`
- [x] `Truncate``tool/truncate.ts` - [x] `File`
- [x] `Vcs``project/vcs.ts` - [x] `Snapshot`
- [x] `Discovery``skill/discovery.ts`
Still open and likely worth migrating: Still open and likely worth migrating:
@@ -130,8 +130,9 @@ Still open and likely worth migrating:
- [ ] `ToolRegistry` - [ ] `ToolRegistry`
- [ ] `Pty` - [ ] `Pty`
- [ ] `Worktree` - [ ] `Worktree`
- [ ] `Installation`
- [ ] `Bus` - [ ] `Bus`
- [x] `Command` - [ ] `Command`
- [ ] `Config` - [ ] `Config`
- [ ] `Session` - [ ] `Session`
- [ ] `SessionProcessor` - [ ] `SessionProcessor`
+360
View File
@@ -0,0 +1,360 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
DeviceCode,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: readonly Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace AccountEffect {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
}
+20 -376
View File
@@ -1,397 +1,41 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" import { Effect, Option } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import { import {
Account as AccountSchema,
type AccountError, type AccountError,
AccessToken, type AccessToken,
AccountID, AccountID,
DeviceCode, AccountEffect,
Info,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID, OrgID,
PollDenied, } from "./effect"
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export { export { AccessToken, AccountID, OrgID } from "./effect"
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export type AccountOrgs = { import { runtime } from "@/effect/runtime"
account: Info
orgs: readonly Org[] function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountEffect.Service.use(f))
} }
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({ function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
config: Schema.Record(Schema.String, Schema.Json), return runtime.runPromise(AccountEffect.Service.use(f))
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
} }
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace Account { export namespace Account {
export interface Interface { export const Account = AccountSchema
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError> export type Account = AccountSchema
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {} export function active(): Account | undefined {
return Option.getOrUndefined(runSync((service) => service.active()))
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const runPromise = makeRunPromise(Service, defaultLayer)
export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active()))
} }
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> { export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
const cfg = await runPromise((service) => service.config(accountID, orgID)) const config = await runPromise((service) => service.config(accountID, orgID))
return Option.getOrUndefined(cfg) return Option.getOrUndefined(config)
} }
export async function token(accountID: AccountID): Promise<AccessToken | undefined> { export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const t = await runPromise((service) => service.token(accountID)) const token = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t) return Option.getOrUndefined(token)
} }
} }
+4 -6
View File
@@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql" import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
export type AccountRow = (typeof AccountTable)["$inferSelect"] export type AccountRow = (typeof AccountTable)["$inferSelect"]
@@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
export namespace AccountRepo { export namespace AccountRepo {
export interface Service { export interface Service {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError> readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
readonly list: () => Effect.Effect<Info[], AccountRepoError> readonly list: () => Effect.Effect<Account[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError> readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError> readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect( static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo, AccountRepo,
Effect.gen(function* () { Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Account)
const query = <A>(f: (db: DbClient) => A) => const query = <A>(f: (db: DbClient) => A) =>
Effect.try({ Effect.try({
@@ -136,8 +136,6 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
.onConflictDoUpdate({ .onConflictDoUpdate({
target: AccountTable.id, target: AccountTable.id,
set: { set: {
email: input.email,
url: input.url,
access_token: input.accessToken, access_token: input.accessToken,
refresh_token: input.refreshToken, refresh_token: input.refreshToken,
token_expiry: input.expiry, token_expiry: input.expiry,
+1 -1
View File
@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
) )
export type UserCode = Schema.Schema.Type<typeof UserCode> export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Info extends Schema.Class<Info>("Account")({ export class Account extends Schema.Class<Account>("Account")({
id: AccountID, id: AccountID,
email: Schema.String, email: Schema.String,
url: Schema.String, url: Schema.String,
+23 -23
View File
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_TITLE from "./prompt/title.txt"
import { Permission } from "@/permission" import { PermissionNext } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda" import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global" import { Global } from "@/global"
import path from "path" import path from "path"
@@ -32,7 +32,7 @@ export namespace Agent {
topP: z.number().optional(), topP: z.number().optional(),
temperature: z.number().optional(), temperature: z.number().optional(),
color: z.string().optional(), color: z.string().optional(),
permission: Permission.Ruleset, permission: PermissionNext.Ruleset,
model: z model: z
.object({ .object({
modelID: ModelID.zod, modelID: ModelID.zod,
@@ -54,7 +54,7 @@ export namespace Agent {
const skillDirs = await Skill.dirs() const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({ const defaults = PermissionNext.fromConfig({
"*": "allow", "*": "allow",
doom_loop: "ask", doom_loop: "ask",
external_directory: { external_directory: {
@@ -72,16 +72,16 @@ export namespace Agent {
"*.env.example": "allow", "*.env.example": "allow",
}, },
}) })
const user = Permission.fromConfig(cfg.permission ?? {}) const user = PermissionNext.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = { const result: Record<string, Info> = {
build: { build: {
name: "build", name: "build",
description: "The default agent. Executes tools based on configured permissions.", description: "The default agent. Executes tools based on configured permissions.",
options: {}, options: {},
permission: Permission.merge( permission: PermissionNext.merge(
defaults, defaults,
Permission.fromConfig({ PermissionNext.fromConfig({
question: "allow", question: "allow",
plan_enter: "allow", plan_enter: "allow",
}), }),
@@ -94,9 +94,9 @@ export namespace Agent {
name: "plan", name: "plan",
description: "Plan mode. Disallows all edit tools.", description: "Plan mode. Disallows all edit tools.",
options: {}, options: {},
permission: Permission.merge( permission: PermissionNext.merge(
defaults, defaults,
Permission.fromConfig({ PermissionNext.fromConfig({
question: "allow", question: "allow",
plan_exit: "allow", plan_exit: "allow",
external_directory: { external_directory: {
@@ -116,9 +116,9 @@ export namespace Agent {
general: { general: {
name: "general", name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge( permission: PermissionNext.merge(
defaults, defaults,
Permission.fromConfig({ PermissionNext.fromConfig({
todoread: "deny", todoread: "deny",
todowrite: "deny", todowrite: "deny",
}), }),
@@ -130,9 +130,9 @@ export namespace Agent {
}, },
explore: { explore: {
name: "explore", name: "explore",
permission: Permission.merge( permission: PermissionNext.merge(
defaults, defaults,
Permission.fromConfig({ PermissionNext.fromConfig({
"*": "deny", "*": "deny",
grep: "allow", grep: "allow",
glob: "allow", glob: "allow",
@@ -161,9 +161,9 @@ export namespace Agent {
native: true, native: true,
hidden: true, hidden: true,
prompt: PROMPT_COMPACTION, prompt: PROMPT_COMPACTION,
permission: Permission.merge( permission: PermissionNext.merge(
defaults, defaults,
Permission.fromConfig({ PermissionNext.fromConfig({
"*": "deny", "*": "deny",
}), }),
user, user,
@@ -177,9 +177,9 @@ export namespace Agent {
native: true, native: true,
hidden: true, hidden: true,
temperature: 0.5, temperature: 0.5,
permission: Permission.merge( permission: PermissionNext.merge(
defaults, defaults,
Permission.fromConfig({ PermissionNext.fromConfig({
"*": "deny", "*": "deny",
}), }),
user, user,
@@ -192,9 +192,9 @@ export namespace Agent {
options: {}, options: {},
native: true, native: true,
hidden: true, hidden: true,
permission: Permission.merge( permission: PermissionNext.merge(
defaults, defaults,
Permission.fromConfig({ PermissionNext.fromConfig({
"*": "deny", "*": "deny",
}), }),
user, user,
@@ -213,7 +213,7 @@ export namespace Agent {
item = result[key] = { item = result[key] = {
name: key, name: key,
mode: "all", mode: "all",
permission: Permission.merge(defaults, user), permission: PermissionNext.merge(defaults, user),
options: {}, options: {},
native: false, native: false,
} }
@@ -229,7 +229,7 @@ export namespace Agent {
item.name = value.name ?? item.name item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {}) item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
} }
// Ensure Truncate.GLOB is allowed unless explicitly configured // Ensure Truncate.GLOB is allowed unless explicitly configured
@@ -242,9 +242,9 @@ export namespace Agent {
}) })
if (explicit) continue if (explicit) continue
result[name].permission = Permission.merge( result[name].permission = PermissionNext.merge(
result[name].permission, result[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
) )
} }
@@ -322,11 +322,11 @@ 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 (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({ const result = streamObject({
...params, ...params,
providerOptions: ProviderTransform.providerOptions(model, { providerOptions: ProviderTransform.providerOptions(model, {
instructions: SystemPrompt.instructions(),
store: false, store: false,
}), }),
onError: () => {}, onError: () => {},
+94
View File
@@ -0,0 +1,94 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export namespace AuthEffect {
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
}
+33 -91
View File
@@ -1,101 +1,43 @@
import path from "path" import { Effect } from "effect"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" import z from "zod"
import { makeRunPromise } from "@/effect/run-service" import { runtime } from "@/effect/runtime"
import { zod } from "@/util/effect-zod" import * as S from "./effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" export { OAUTH_DUMMY_KEY } from "./effect"
const file = path.join(Global.Path.data, "auth.json") function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
return runtime.runPromise(S.AuthEffect.Service.use(f))
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause }) }
export namespace Auth { export namespace Auth {
export class Oauth extends Schema.Class<Oauth>("OAuth")({ export const Oauth = z
type: Schema.Literal("oauth"), .object({
refresh: Schema.String, type: z.literal("oauth"),
access: Schema.String, refresh: z.string(),
expires: Schema.Number, access: z.string(),
accountId: Schema.optional(Schema.String), expires: z.number(),
enterpriseUrl: Schema.optional(Schema.String), accountId: z.string().optional(),
}) {} enterpriseUrl: z.string().optional(),
})
.meta({ ref: "OAuth" })
export class Api extends Schema.Class<Api>("ApiAuth")({ export const Api = z
type: Schema.Literal("api"), .object({
key: Schema.String, type: z.literal("api"),
}) {} key: z.string(),
})
.meta({ ref: "ApiAuth" })
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({ export const WellKnown = z
type: Schema.Literal("wellknown"), .object({
key: Schema.String, type: z.literal("wellknown"),
token: Schema.String, key: z.string(),
}) {} token: z.string(),
})
.meta({ ref: "WellKnownAuth" })
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) }) export type Info = z.infer<typeof Info>
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function get(providerID: string) { export async function get(providerID: string) {
return runPromise((service) => service.get(providerID)) return runPromise((service) => service.get(providerID))
+10 -9
View File
@@ -1,7 +1,8 @@
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect" import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account" import { runtime } from "@/effect/runtime"
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { type AccountError } from "@/account/schema" import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"
@@ -16,7 +17,7 @@ const isActiveOrgChoice = (
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) { const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* Account.Service const service = yield* AccountEffect.Service
yield* Prompt.intro("Log in") yield* Prompt.intro("Log in")
const login = yield* service.login(url) const login = yield* service.login(url)
@@ -57,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
}) })
const logoutEffect = Effect.fn("logout")(function* (email?: string) { const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* Account.Service const service = yield* AccountEffect.Service
const accounts = yield* service.list() const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in") if (accounts.length === 0) return yield* println("Not logged in")
@@ -97,7 +98,7 @@ interface OrgChoice {
} }
const switchEffect = Effect.fn("switch")(function* () { const switchEffect = Effect.fn("switch")(function* () {
const service = yield* Account.Service const service = yield* AccountEffect.Service
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in") if (groups.length === 0) return yield* println("Not logged in")
@@ -128,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
}) })
const orgsEffect = Effect.fn("orgs")(function* () { const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* Account.Service const service = yield* AccountEffect.Service
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found") if (groups.length === 0) return yield* println("No accounts found")
@@ -159,7 +160,7 @@ export const LoginCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await Account.runPromise((_svc) => loginEffect(args.url)) await runtime.runPromise(loginEffect(args.url))
}, },
}) })
@@ -173,7 +174,7 @@ export const LogoutCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await Account.runPromise((_svc) => logoutEffect(args.email)) await runtime.runPromise(logoutEffect(args.email))
}, },
}) })
@@ -182,7 +183,7 @@ export const SwitchCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await Account.runPromise((_svc) => switchEffect()) await runtime.runPromise(switchEffect())
}, },
}) })
@@ -191,7 +192,7 @@ export const OrgsCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await Account.runPromise((_svc) => orgsEffect()) await runtime.runPromise(orgsEffect())
}, },
}) })
+1 -1
View File
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp" process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts) const server = await Server.listen(opts)
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`, baseUrl: `http://${server.hostname}:${server.port}`,
+6 -6
View File
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema" import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry" import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { Permission } from "../../../permission" import { PermissionNext } from "../../../permission"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
} }
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) { async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
const disabled = Permission.disabled( const disabled = PermissionNext.disabled(
availableTools.map((tool) => tool.id), availableTools.map((tool) => tool.id),
agent.permission, agent.permission,
) )
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
} }
await Session.updateMessage(message) await Session.updateMessage(message)
const ruleset = Permission.merge(agent.permission, session.permission ?? []) const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
return { return {
sessionID: session.id, sessionID: session.id,
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
abort: new AbortController().signal, abort: new AbortController().signal,
messages: [], messages: [],
metadata: () => {}, metadata: () => {},
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) { async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) { for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset) const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") { if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset }) throw new PermissionNext.DeniedError({ ruleset })
} }
} }
}, },
+2 -2
View File
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Permission } from "../../permission" import { PermissionNext } from "../../permission"
import { Tool } from "../../tool/tool" import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob" import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep" import { GrepTool } from "../../tool/grep"
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
process.exit(1) process.exit(1)
} }
const rules: Permission.Ruleset = [ const rules: PermissionNext.Ruleset = [
{ {
permission: "question", permission: "question",
action: "deny", action: "deny",
+1 -1
View File
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
} }
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts) const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`) console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {}) await new Promise(() => {})
@@ -1667,7 +1667,6 @@ function InlineTool(props: {
const denied = createMemo( const denied = createMemo(
() => () =>
error()?.includes("QuestionRejectedError") ||
error()?.includes("rejected permission") || error()?.includes("rejected permission") ||
error()?.includes("specified a rule") || error()?.includes("specified a rule") ||
error()?.includes("user dismissed"), error()?.includes("user dismissed"),
+2 -2
View File
@@ -58,10 +58,10 @@ export const UpgradeCommand = {
spinner.stop("Upgrade failed", 1) spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) { if (err instanceof Installation.UpgradeFailedError) {
// necessary because choco only allows install/upgrade in elevated terminals // necessary because choco only allows install/upgrade in elevated terminals
if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) { if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
prompts.log.error("Please run the terminal as Administrator and try again") prompts.log.error("Please run the terminal as Administrator and try again")
} else { } else {
prompts.log.error(err.stderr) prompts.log.error(err.data.stderr)
} }
} else if (err instanceof Error) prompts.log.error(err.message) } else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done") prompts.outro("Done")
+1 -1
View File
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
} }
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts) const server = await Server.listen(opts)
UI.empty() UI.empty()
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
UI.empty() UI.empty()
+86 -120
View File
@@ -1,23 +1,15 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, ServiceMap } from "effect"
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp" import { Instance } from "../project/instance"
import { Skill } from "../skill" import { Identifier } from "../id/id"
import { Log } from "../util/log"
import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt" import PROMPT_REVIEW from "./template/review.txt"
import { MCP } from "../mcp"
import { Skill } from "../skill"
export namespace Command { export namespace Command {
const log = Log.create({ service: "command" })
type State = {
commands: Record<string, Info>
}
export const Event = { export const Event = {
Executed: BusEvent.define( Executed: BusEvent.define(
"command.executed", "command.executed",
@@ -50,7 +42,7 @@ export namespace Command {
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string } export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
export function hints(template: string) { export function hints(template: string): string[] {
const result: string[] = [] const result: string[] = []
const numbered = template.match(/\$\d+/g) const numbered = template.match(/\$\d+/g)
if (numbered) { if (numbered) {
@@ -65,121 +57,95 @@ export namespace Command {
REVIEW: "review", REVIEW: "review",
} as const } as const
export interface Interface { const state = Instance.state(async () => {
readonly get: (name: string) => Effect.Effect<Info | undefined> const cfg = await Config.get()
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {} const result: Record<string, Info> = {
[Default.INIT]: {
name: Default.INIT,
description: "create/update AGENTS.md",
source: "command",
get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
},
hints: hints(PROMPT_INITIALIZE),
},
[Default.REVIEW]: {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
},
}
export const layer = Layer.effect( for (const [name, command] of Object.entries(cfg.command ?? {})) {
Service, result[name] = {
Effect.gen(function* () { name,
const init = Effect.fn("Command.state")(function* (ctx) { agent: command.agent,
const cfg = yield* Effect.promise(() => Config.get()) model: command.model,
const commands: Record<string, Info> = {} description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
// since a getter can't be async we need to manually return a promise here
return new Promise<string>(async (resolve, reject) => {
const template = await MCP.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? // substitute each argument with $1, $2, etc.
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
).catch(reject)
resolve(
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
)
})
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
commands[Default.INIT] = { // Add skills as invokable commands
name: Default.INIT, for (const skill of await Skill.all()) {
description: "create/update AGENTS.md", // Skip if a command with this name already exists
source: "command", if (result[skill.name]) continue
get template() { result[skill.name] = {
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) name: skill.name,
}, description: skill.description,
hints: hints(PROMPT_INITIALIZE), source: "skill",
} get template() {
commands[Default.REVIEW] = { return skill.content
name: Default.REVIEW, },
description: "review changes [commit|branch|pr], defaults to uncommitted", hints: [],
source: "command", }
get template() { }
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
}
for (const [name, command] of Object.entries(cfg.command ?? {})) { return result
commands[name] = { })
name,
agent: command.agent,
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
commands[name] = {
name,
source: "mcp",
description: prompt.description,
get template() {
return new Promise<string>(async (resolve, reject) => {
const template = await MCP.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
).catch(reject)
resolve(
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
)
})
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
for (const skill of yield* Effect.promise(() => Skill.all())) {
if (commands[skill.name]) continue
commands[skill.name] = {
name: skill.name,
description: skill.description,
source: "skill",
get template() {
return skill.content
},
hints: [],
}
}
return {
commands,
}
})
const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
const get = Effect.fn("Command.get")(function* (name: string) {
const state = yield* InstanceState.get(cache)
return state.commands[name]
})
const list = Effect.fn("Command.list")(function* () {
const state = yield* InstanceState.get(cache)
return Object.values(state.commands)
})
return Service.of({ get, list })
}),
)
const runPromise = makeRunPromise(Service, layer)
export async function get(name: string) { export async function get(name: string) {
return runPromise((svc) => svc.get(name)) return state().then((x) => x[name])
} }
export async function list() { export async function list() {
return runPromise((svc) => svc.list()) return state().then((x) => Object.values(x))
} }
} }
+1 -1
View File
@@ -177,7 +177,7 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
const active = await Account.active() const active = Account.active()
if (active?.active_org_id) { if (active?.active_org_id) {
try { try {
const [config, token] = await Promise.all([ const [config, token] = await Promise.all([
@@ -1,3 +1,4 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono" import { Hono } from "hono"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap" import { InstanceBootstrap } from "../../project/bootstrap"
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
} }
export function Listen(opts: { hostname: string; port: number }) { export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({ const server = createAdaptorServer({
hostname: opts.hostname,
port: opts.port,
fetch: App().fetch, fetch: App().fetch,
}) })
server.listen(opts.port, opts.hostname)
return {
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
} }
} }
@@ -1,47 +0,0 @@
import { Effect, ScopedCache, Scope } from "effect"
import { Instance, type Shape } from "@/project/instance"
import { registerDisposer } from "./instance-registry"
const TypeId = "~opencode/InstanceState"
export interface InstanceState<A, E = never, R = never> {
readonly [TypeId]: typeof TypeId
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export namespace InstanceState {
export const make = <A, E = never, R = never>(
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: () => init(Instance.current),
})
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
yield* Effect.addFinalizer(() => Effect.sync(off))
return {
[TypeId]: TypeId,
cache,
}
})
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
Effect.map(get(self), select)
export const useEffect = <A, E, R, B, E2, R2>(
self: InstanceState<A, E, R>,
select: (value: A) => Effect.Effect<B, E2, R2>,
) => Effect.flatMap(get(self), select)
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
}
+68
View File
@@ -0,0 +1,68 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { File } from "@/file"
import { FileTime } from "@/file/time"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { PermissionNext } from "@/permission"
import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { ProviderAuth } from "@/provider/auth"
import { Question } from "@/question"
import { Skill } from "@/skill/skill"
import { Snapshot } from "@/snapshot"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
export { InstanceContext } from "./instance-context"
export type InstanceServices =
| Question.Service
| PermissionNext.Service
| ProviderAuth.Service
| FileWatcher.Service
| Vcs.Service
| FileTime.Service
| Format.Service
| File.Service
| Skill.Service
| Snapshot.Service
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
// legacy Instance ALS here, which is safe because lookup is only triggered via
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
// This should go away once the old Instance type is removed and lookup can load
// the full context directly.
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
Layer.fresh(Question.layer),
Layer.fresh(PermissionNext.layer),
Layer.fresh(ProviderAuth.defaultLayer),
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
Layer.fresh(Vcs.layer),
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
Layer.fresh(Format.layer),
Layer.fresh(File.layer),
Layer.fresh(Skill.defaultLayer),
Layer.fresh(Snapshot.defaultLayer),
).pipe(Layer.provide(ctx))
}
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
"opencode/Instances",
) {
static readonly layer = Layer.effect(
Instances,
Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister))
return Instances.of(layerMap)
}),
)
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
}
}
@@ -1,13 +0,0 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap"
export const memoMap = Layer.makeMemoMapUnsafe()
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
rt ??= ManagedRuntime.make(layer, { memoMap })
return rt.runPromise(service.use(fn), options)
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { AccountEffect } from "@/account/effect"
import { AuthEffect } from "@/auth/effect"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(
AccountEffect.defaultLayer, //
TruncateEffect.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(AuthEffect.layer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
export function disposeRuntime() {
return runtime.dispose()
}
+65 -82
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceContext } from "@/effect/instance-context"
import { makeRunPromise } from "@/effect/run-service" import { runPromiseInstance } from "@/effect/runtime"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff" import { formatPatch, structuredPatch } from "diff"
@@ -83,6 +83,26 @@ export namespace File {
), ),
} }
export function init() {
return runPromiseInstance(Service.use((svc) => svc.init()))
}
export async function status() {
return runPromiseInstance(Service.use((svc) => svc.status()))
}
export async function read(file: string): Promise<Content> {
return runPromiseInstance(Service.use((svc) => svc.read(file)))
}
export async function list(dir?: string) {
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(Service.use((svc) => svc.search(input)))
}
const log = Log.create({ service: "file" }) const log = Log.create({ service: "file" })
const binary = new Set([ const binary = new Set([
@@ -179,6 +199,12 @@ export namespace File {
"efi", "efi",
"rom", "rom",
"com", "com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
]) ])
const image = new Set([ const image = new Set([
@@ -297,7 +323,7 @@ export namespace File {
function shouldEncode(mimeType: string) { function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase() const type = mimeType.toLowerCase()
log.debug("shouldEncode", { type }) log.info("shouldEncode", { type })
if (!type) return false if (!type) return false
if (type.startsWith("text/")) return false if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false if (type.includes("charset=")) return false
@@ -321,11 +347,6 @@ export namespace File {
return [...visible, ...hiddenItems] return [...visible, ...hiddenItems]
} }
interface State {
cache: Entry
fiber: Fiber.Fiber<void> | undefined
}
export interface Interface { export interface Interface {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]> readonly status: () => Effect.Effect<File.Info[]>
@@ -344,18 +365,12 @@ export namespace File {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make<State>( const instance = yield* InstanceContext
Effect.fn("File.state")(() => let cache: Entry = { files: [], dirs: [] }
Effect.succeed({ const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
cache: { files: [], dirs: [] } as Entry,
fiber: undefined as Fiber.Fiber<void> | undefined,
}),
),
)
const scan = Effect.fn("File.scan")(function* () { const scan = Effect.fn("File.scan")(function* () {
if (Instance.directory === path.parse(Instance.directory).root) return if (instance.directory === path.parse(instance.directory).root) return
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
const next: Entry = { files: [], dirs: [] } const next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
@@ -366,7 +381,7 @@ export namespace File {
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises const top = await fs.promises
.readdir(Instance.directory, { withFileTypes: true }) .readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[]) .catch(() => [] as fs.Dirent[])
for (const entry of top) { for (const entry of top) {
@@ -374,7 +389,7 @@ export namespace File {
if (shouldIgnoreName(entry.name)) continue if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/") dirs.add(entry.name + "/")
const base = path.join(Instance.directory, entry.name) const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) { for (const child of children) {
if (!child.isDirectory()) continue if (!child.isDirectory()) continue
@@ -386,7 +401,7 @@ export namespace File {
next.dirs = Array.from(dirs).toSorted() next.dirs = Array.from(dirs).toSorted()
} else { } else {
const seen = new Set<string>() const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: Instance.directory })) { for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file) next.files.push(file)
let current = file let current = file
while (true) { while (true) {
@@ -402,38 +417,31 @@ export namespace File {
} }
}) })
const s = yield* InstanceState.get(state) cache = next
s.cache = next
}) })
const getFiles = () => cache
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
let fiber: Fiber.Fiber<void> | undefined
const ensure = Effect.fn("File.ensure")(function* () {
const s = yield* InstanceState.get(state)
if (!s.fiber)
s.fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.ensuring(
Effect.sync(() => {
s.fiber = undefined
}),
),
Effect.forkIn(scope),
)
yield* Fiber.join(s.fiber)
})
const init = Effect.fn("File.init")(function* () { const init = Effect.fn("File.init")(function* () {
yield* ensure() if (!fiber) {
fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.forkIn(scope),
)
}
yield* Fiber.join(fiber)
}) })
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 () => { return yield* Effect.promise(async () => {
const diffOutput = ( const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory, cwd: instance.directory,
}) })
).text() ).text()
@@ -463,7 +471,7 @@ export namespace File {
"--exclude-standard", "--exclude-standard",
], ],
{ {
cwd: Instance.directory, cwd: instance.directory,
}, },
) )
).text() ).text()
@@ -471,7 +479,7 @@ export namespace File {
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) { for (const file of untrackedOutput.trim().split("\n")) {
try { try {
const content = await Filesystem.readText(path.join(Instance.directory, file)) const content = await Filesystem.readText(path.join(instance.directory, file))
changed.push({ changed.push({
path: file, path: file,
added: content.split("\n").length, added: content.split("\n").length,
@@ -497,7 +505,7 @@ export namespace File {
"HEAD", "HEAD",
], ],
{ {
cwd: Instance.directory, cwd: instance.directory,
}, },
) )
).text() ).text()
@@ -514,10 +522,10 @@ export namespace File {
} }
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),
} }
}) })
}) })
@@ -526,7 +534,7 @@ export namespace File {
const read = Effect.fn("File.read")(function* (file: string) { const read = Effect.fn("File.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => { return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const full = path.join(Instance.directory, file) const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) { if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory") throw new Error("Access denied: path escapes project directory")
@@ -574,19 +582,19 @@ export namespace File {
const content = (await Filesystem.readText(full).catch(() => "")).trim() const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (Instance.project.vcs === "git") { if (instance.project.vcs === "git") {
let diff = ( let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
).text() ).text()
if (!diff.trim()) { if (!diff.trim()) {
diff = ( diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory, cwd: instance.directory,
}) })
).text() ).text()
} }
if (diff.trim()) { if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", { const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity, context: Infinity,
ignoreWhitespace: true, ignoreWhitespace: true,
@@ -608,20 +616,20 @@ export namespace File {
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"] const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false let ignored = (_: string) => false
if (Instance.project.vcs === "git") { if (instance.project.vcs === "git") {
const ig = ignore() const ig = ignore()
const gitignore = path.join(Instance.project.worktree, ".gitignore") const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) { if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore)) ig.add(await Filesystem.readText(gitignore))
} }
const ignoreFile = path.join(Instance.project.worktree, ".ignore") const ignoreFile = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) { if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignoreFile)) ig.add(await Filesystem.readText(ignoreFile))
} }
ignored = ig.ignores.bind(ig) ignored = ig.ignores.bind(ig)
} }
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) { if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory") throw new Error("Access denied: path escapes project directory")
} }
@@ -630,7 +638,7 @@ export namespace File {
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name) const absolute = path.join(resolved, entry.name)
const file = path.relative(Instance.directory, absolute) const file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file" const type = entry.isDirectory() ? "directory" : "file"
nodes.push({ nodes.push({
name: entry.name, name: entry.name,
@@ -654,16 +662,13 @@ export namespace File {
dirs?: boolean dirs?: boolean
type?: "file" | "directory" type?: "file" | "directory"
}) { }) {
yield* ensure()
const { cache } = yield* InstanceState.get(state)
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const query = input.query.trim() const query = input.query.trim()
const limit = input.limit ?? 100 const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all") const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind }) log.info("search", { query, kind })
const result = cache const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.") const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) { if (!query) {
@@ -687,26 +692,4 @@ export namespace File {
return Service.of({ init, status, read, list, search }) return Service.of({ init, status, read, list, search })
}), }),
) )
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
} }
+11 -29
View File
@@ -1,6 +1,5 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { runPromiseInstance } from "@/effect/runtime"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -36,11 +35,6 @@ export namespace FileTime {
return next return next
} }
interface State {
reads: Map<SessionID, Map<string, Stamp>>
locks: Map<string, Semaphore.Semaphore>
}
export interface Interface { export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void> readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined> readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
@@ -54,40 +48,30 @@ export namespace FileTime {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const state = yield* InstanceState.make<State>( const reads = new Map<SessionID, Map<string, Stamp>>()
Effect.fn("FileTime.state")(() => const locks = new Map<string, Semaphore.Semaphore>()
Effect.succeed({
reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(),
}),
),
)
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { const getLock = (filepath: string) => {
const locks = (yield* InstanceState.get(state)).locks
const lock = locks.get(filepath) const lock = locks.get(filepath)
if (lock) return lock if (lock) return lock
const next = Semaphore.makeUnsafe(1) const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next) locks.set(filepath, next)
return next return next
}) }
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file }) log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file)) session(reads, sessionID).set(file, yield* stamp(file))
}) })
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read return reads.get(sessionID)?.get(file)?.read
}) })
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return if (disableCheck) return
const reads = (yield* InstanceState.get(state)).reads
const time = reads.get(sessionID)?.get(filepath) const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
@@ -101,28 +85,26 @@ export namespace FileTime {
}) })
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) { const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1)) return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
}) })
return Service.of({ read, get, assert, withLock }) return Service.of({ read, get, assert, withLock })
}), }),
).pipe(Layer.orDie) )
const runPromise = makeRunPromise(Service, layer)
export function read(sessionID: SessionID, file: string) { export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file)) return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
} }
export function get(sessionID: SessionID, file: string) { export function get(sessionID: SessionID, file: string) {
return runPromise((s) => s.get(sessionID, file)) return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
} }
export async function assert(sessionID: SessionID, filepath: string) { export async function assert(sessionID: SessionID, filepath: string) {
return runPromise((s) => s.assert(sessionID, filepath)) return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
} }
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, fn)) return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
} }
} }
+64 -90
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect" import { Cause, Effect, Layer, ServiceMap } from "effect"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
@@ -7,8 +7,7 @@ import path from "path"
import z from "zod" import z from "zod"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceContext } from "@/effect/instance-context"
import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { git } from "@/util/git" import { git } from "@/util/git"
@@ -61,107 +60,82 @@ export namespace FileWatcher {
export const hasNativeBinding = () => !!watcher() export const hasNativeBinding = () => !!watcher()
export interface Interface { export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
readonly init: () => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make( const instance = yield* InstanceContext
Effect.fn("FileWatcher.state")( if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
log.info("init", { directory: Instance.directory }) log.info("init", { directory: instance.directory })
const backend = getBackend() const backend = getBackend()
if (!backend) { if (!backend) {
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
return return Service.of({})
} }
const w = watcher() const w = watcher()
if (!w) return if (!w) return Service.of({})
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = [] const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return if (err) return
for (const evt of evts) { for (const evt of evts) {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
} }
}) })
const subscribe = (dir: string, ignore: string[]) => { const subscribe = (dir: string, ignore: string[]) => {
const pending = w.subscribe(dir, cb, { ignore, backend }) const pending = w.subscribe(dir, cb, { ignore, backend })
return Effect.gen(function* () { return Effect.gen(function* () {
const sub = yield* Effect.promise(() => pending) const sub = yield* Effect.promise(() => pending)
subs.push(sub) subs.push(sub)
}).pipe( }).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
)
}
const cfg = yield* Effect.promise(() => Config.get())
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
}
if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
git(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
}),
)
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
},
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void return Effect.void
}), }),
), )
) }
return Service.of({ const cfg = yield* Effect.promise(() => Config.get())
init: Effect.fn("FileWatcher.init")(function* () { const cfgIgnores = cfg.watcher?.ignore ?? []
yield* InstanceState.get(state)
}), if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
}) yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
}), }
if (instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
git(["rev-parse", "--git-dir"], {
cwd: instance.project.worktree,
}),
)
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
)
yield* subscribe(vcsDir, ignore)
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({}))
}),
),
) )
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())
}
} }
-1
View File
@@ -69,7 +69,6 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"] export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB") export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS") export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS") export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
+91 -116
View File
@@ -1,6 +1,6 @@
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { runPromiseInstance } from "@/effect/runtime"
import { makeRunPromise } from "@/effect/run-service" import { InstanceContext } from "@/effect/instance-context"
import path from "path" import path from "path"
import { mergeDeep } from "remeda" import { mergeDeep } from "remeda"
import z from "zod" import z from "zod"
@@ -27,7 +27,6 @@ export namespace Format {
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
export interface Interface { export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]> readonly status: () => Effect.Effect<Status[]>
} }
@@ -36,124 +35,106 @@ export namespace Format {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make( const instance = yield* InstanceContext
Effect.fn("Format.state")(function* (_ctx) {
const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
const cfg = yield* Effect.promise(() => Config.get()) const enabled: Record<string, boolean> = {}
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter !== false) { const cfg = yield* Effect.promise(() => Config.get())
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (info.command.length === 0) continue if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) {
formatters[name] = { formatters[item.name] = item
...info, }
name, for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
enabled: async () => true, if (item.disabled) {
} delete formatters[name]
} continue
} else {
log.info("all formatters are disabled")
} }
const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
async function isEnabled(item: Formatter.Info) { if (info.command.length === 0) continue
let status = enabled[item.name]
if (status === undefined) { formatters[name] = {
status = await item.enabled() ...info,
enabled[item.name] = status name,
} enabled: async () => true,
return status
} }
}
} else {
log.info("all formatters are disabled")
}
async function getFormatter(ext: string) { async function isEnabled(item: Formatter.Info) {
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) let status = enabled[item.name]
const checks = await Promise.all( if (status === undefined) {
matching.map(async (item) => { status = await item.enabled()
log.info("checking", { name: item.name, ext }) enabled[item.name] = status
const on = await isEnabled(item) }
if (on) { return status
log.info("enabled", { name: item.name, ext }) }
}
return {
item,
enabled: on,
}
}),
)
return checks.filter((x) => x.enabled).map((x) => x.item)
}
yield* Effect.acquireRelease( async function getFormatter(ext: string) {
Effect.sync(() => const result = []
Bus.subscribe( for (const item of Object.values(formatters)) {
File.Event.Edited, log.info("checking", { name: item.name, ext })
Instance.bind(async (payload) => { if (!item.extensions.includes(ext)) continue
const file = payload.properties.file if (!(await isEnabled(item))) continue
log.info("formatting", { file }) log.info("enabled", { name: item.name, ext })
const ext = path.extname(file) result.push(item)
}
return result
}
for (const item of await getFormatter(ext)) { yield* Effect.acquireRelease(
log.info("running", { command: item.command }) Effect.sync(() =>
try { Bus.subscribe(
const proc = Process.spawn( File.Event.Edited,
item.command.map((x) => x.replace("$FILE", file)), Instance.bind(async (payload) => {
{ const file = payload.properties.file
cwd: Instance.directory, log.info("formatting", { file })
env: { ...process.env, ...item.environment }, const ext = path.extname(file)
stdout: "ignore",
stderr: "ignore", for (const item of await getFormatter(ext)) {
}, log.info("running", { command: item.command })
) try {
const exit = await proc.exited const proc = Process.spawn(
if (exit !== 0) { item.command.map((x) => x.replace("$FILE", file)),
log.error("failed", { {
command: item.command, cwd: instance.directory,
...item.environment, env: { ...process.env, ...item.environment },
}) stdout: "ignore",
} stderr: "ignore",
} catch (error) { },
log.error("failed to format file", { )
error, const exit = await proc.exited
command: item.command, if (exit !== 0) {
...item.environment, log.error("failed", {
file, command: item.command,
}) ...item.environment,
} })
} }
}), } catch (error) {
), log.error("failed to format file", {
), error,
(unsubscribe) => Effect.sync(unsubscribe), command: item.command,
) ...item.environment,
log.info("init") file,
})
return { }
formatters, }
isEnabled, }),
} ),
}), ),
(unsubscribe) => Effect.sync(unsubscribe),
) )
log.info("init")
const init = Effect.fn("Format.init")(function* () {
yield* InstanceState.get(state)
})
const status = Effect.fn("Format.status")(function* () { const status = Effect.fn("Format.status")(function* () {
const { formatters, isEnabled } = yield* InstanceState.get(state)
const result: Status[] = [] const result: Status[] = []
for (const formatter of Object.values(formatters)) { for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter)) const isOn = yield* Effect.promise(() => isEnabled(formatter))
@@ -166,17 +147,11 @@ export namespace Format {
return result return result
}) })
return Service.of({ init, status }) return Service.of({ status })
}), }),
) )
const runPromise = makeRunPromise(Service, layer)
export async function init() {
return runPromise((s) => s.init())
}
export async function status() { export async function status() {
return runPromise((s) => s.status()) return runPromiseInstance(Service.use((s) => s.status()))
} }
} }
+243 -290
View File
@@ -1,14 +1,12 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { BusEvent } from "@/bus/bus-event"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { BusEvent } from "@/bus/bus-event" import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag"
import { Log } from "../util/log" import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { Flag } from "../flag/flag"
import { Process } from "@/util/process"
import { buffer } from "node:stream/consumers"
declare global { declare global {
const OPENCODE_VERSION: string const OPENCODE_VERSION: string
@@ -18,7 +16,39 @@ declare global {
export namespace Installation { export namespace Installation {
const log = Log.create({ service: "installation" }) const log = Log.create({ service: "installation" })
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export const Event = { export const Event = {
Updated: BusEvent.define( Updated: BusEvent.define(
@@ -45,9 +75,12 @@ export namespace Installation {
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export async function info() {
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" return {
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` version: VERSION,
latest: await latest(),
}
}
export function isPreview() { export function isPreview() {
return CHANNEL !== "latest" return CHANNEL !== "latest"
@@ -57,294 +90,214 @@ export namespace Installation {
return CHANNEL === "local" return CHANNEL === "local"
} }
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", { export async function method() {
stderr: Schema.String, if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
}) {} if (process.execPath.includes(path.join(".local", "bin"))) return "curl"
const exec = process.execPath.toLowerCase()
// Response schemas for external version APIs const checks = [
const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) {
const NpmPackage = Schema.Struct({ version: Schema.String }) name: "npm" as const,
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) }) command: () => text(["npm", "list", "-g", "--depth=0"]),
const BrewInfoV2 = Schema.Struct({ },
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })), {
}) name: "yarn" as const,
const ChocoPackage = Schema.Struct({ command: () => text(["yarn", "global", "list"]),
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), },
}) {
const ScoopManifest = NpmPackage name: "pnpm" as const,
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
},
{
name: "bun" as const,
command: () => text(["bun", "pm", "ls", "-g"]),
},
{
name: "brew" as const,
command: () => text(["brew", "list", "--formula", "opencode"]),
},
{
name: "scoop" as const,
command: () => text(["scoop", "list", "opencode"]),
},
{
name: "choco" as const,
command: () => text(["choco", "list", "--limit-output", "opencode"]),
},
]
export interface Interface { checks.sort((a, b) => {
readonly info: () => Effect.Effect<Info> const aMatches = exec.includes(a.name)
readonly method: () => Effect.Effect<Method> const bMatches = exec.includes(b.name)
readonly latest: (method?: Method) => Effect.Effect<string> if (aMatches && !bMatches) return -1
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError> if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = await check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown"
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {} export const UpgradeFailedError = NamedError.create(
"UpgradeFailedError",
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> = z.object({
Layer.effect( stderr: z.string(),
Service, }),
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const text = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
yield* handle.exitCode
return out
},
Effect.scoped,
Effect.catch(() => Effect.succeed("")),
)
const run = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, stdout, stderr }
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)
const getBrewFormula = Effect.fnUntraced(function* () {
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
})
const upgradeCurl = Effect.fnUntraced(
function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const proc = ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
env: { VERSION: target },
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, stdout, stderr }
},
Effect.scoped,
Effect.orDie,
)
const methodImpl = Effect.fn("Installation.method")(function* () {
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
]
checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})
for (const check of checks) {
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown" as Method
})
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
const detectedMethod = installMethod || (yield* methodImpl())
if (detectedMethod === "brew") {
const formula = yield* getBrewFormula()
if (formula.includes("/")) {
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
return info.formulae[0].versions.stable
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
return data.versions.stable
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
const channel = CHANNEL
const response = yield* httpOk.execute(
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}
if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}
if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
}, Effect.orDie)
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
switch (m) {
case "curl":
result = yield* upgradeCurl(target)
break
case "npm":
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
case "pnpm":
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
break
case "bun":
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
break
case "brew": {
const formula = yield* getBrewFormula()
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
if (formula.includes("/")) {
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
if (tap.code !== 0) {
result = tap
break
}
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
const dir = repo.trim()
if (dir) {
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = yield* run(["brew", "upgrade", formula], { env })
break
}
case "choco":
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
break
case "scoop":
result = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
}
if (!result || result.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
}
log.info("upgraded", {
method: m,
target,
stdout: result.stdout,
stderr: result.stderr,
})
yield* text([process.execPath, "--version"])
})
return Service.of({
info: Effect.fn("Installation.info")(function* () {
return {
version: VERSION,
latest: yield* latestImpl(),
}
}),
method: methodImpl,
latest: latestImpl,
upgrade: upgradeImpl,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
) )
const runPromise = makeRunPromise(Service, defaultLayer) async function getBrewFormula() {
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
export async function info(): Promise<Info> { if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
return runPromise((svc) => svc.info()) const coreFormula = await text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
} }
export async function method(): Promise<Method> { export async function upgrade(method: Method, target: string) {
return runPromise((svc) => svc.method()) let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
switch (method) {
case "curl":
result = await upgradeCurl(target)
break
case "npm":
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "pnpm":
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "bun":
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "brew": {
const formula = await getBrewFormula()
const env = {
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
}
if (formula.includes("/")) {
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
if (tap.code !== 0) {
result = tap
break
}
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
if (repo.code !== 0) {
result = repo
break
}
const dir = repo.text.trim()
if (dir) {
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break
}
case "choco":
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
break
case "scoop":
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
break
default:
throw new Error(`Unknown method: ${method}`)
}
if (!result || result.code !== 0) {
const stderr =
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
throw new UpgradeFailedError({
stderr: stderr,
})
}
log.info("upgraded", {
method,
target,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
await Process.text([process.execPath, "--version"], { nothrow: true })
} }
export async function latest(installMethod?: Method): Promise<string> { export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
return runPromise((svc) => svc.latest(installMethod)) export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
} export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
export async function upgrade(m: Method, target: string): Promise<void> { export async function latest(installMethod?: Method) {
return runPromise((svc) => svc.upgrade(m, target)) const detectedMethod = installMethod || (await method())
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula.includes("/")) {
const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
return version
}
return fetch("https://formulae.brew.sh/api/formula/opencode.json")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.versions.stable)
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => {
const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const channel = CHANNEL
return fetch(`${registry}/opencode-ai/${channel}`)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
if (detectedMethod === "choco") {
return fetch(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
{ headers: { Accept: "application/json;odata=verbose" } },
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.d.results[0].Version)
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.tag_name.replace(/^v/, ""))
} }
} }
-1
View File
@@ -1 +0,0 @@
export { Server } from "./server/server"
@@ -1,15 +0,0 @@
import { Wildcard } from "@/util/wildcard"
type Rule = {
permission: string
pattern: string
action: "allow" | "deny" | "ask"
}
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
const rules = rulesets.flat()
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
+24 -51
View File
@@ -1,22 +1,21 @@
import { runPromiseInstance } from "@/effect/runtime"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state" import { InstanceContext } from "@/effect/instance-context"
import { makeRunPromise } from "@/effect/run-service"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema" import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql" import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db" import { Database, eq } from "@/storage/db"
import { fn } from "@/util/fn"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os" import os from "os"
import z from "zod" import z from "zod"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema" import { PermissionID } from "./schema"
export namespace Permission { export namespace PermissionNext {
const log = Log.create({ service: "permission" }) const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({ export const Action = z.enum(["allow", "deny", "ask"]).meta({
@@ -125,46 +124,28 @@ export namespace Permission {
deferred: Deferred.Deferred<void, RejectedError | CorrectedError> deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
} }
interface State {
pending: Map<PermissionID, PendingEntry>
approved: Ruleset
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) const rules = rulesets.flat()
return evalRule(permission, pattern, ...rulesets) log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make<State>( const { project } = yield* InstanceContext
Effect.fn("Permission.state")(function* (ctx) { const row = Database.use((db) =>
const row = Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
)
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [],
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
)
return state
}),
) )
const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) { const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input const { ruleset, ...request } = input
let needsAsk = false let needsAsk = false
@@ -201,7 +182,6 @@ export namespace Permission {
}) })
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) { const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID) const existing = pending.get(input.requestID)
if (!existing) return if (!existing) return
@@ -259,7 +239,6 @@ export namespace Permission {
}) })
const list = Effect.fn("Permission.list")(function* () { const list = Effect.fn("Permission.list")(function* () {
const pending = (yield* InstanceState.get(state)).pending
return Array.from(pending.values(), (item) => item.info) return Array.from(pending.values(), (item) => item.info)
}) })
@@ -293,6 +272,14 @@ export namespace Permission {
return rulesets.flat() return rulesets.flat()
} }
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
export async function list() {
return runPromiseInstance(Service.use((svc) => svc.list()))
}
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> { export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
@@ -305,18 +292,4 @@ export namespace Permission {
} }
return result return result
} }
export const runPromise = makeRunPromise(Service, layer)
export async function ask(input: z.infer<typeof AskInput>) {
return runPromise((s) => s.ask(input))
}
export async function reply(input: z.infer<typeof ReplyInput>) {
return runPromise((s) => s.reply(input))
}
export async function list() {
return runPromise((s) => s.list())
}
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex"
import { Session } from "../session" import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot" import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@@ -1,11 +1,7 @@
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { File } from "../file" import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Snapshot } from "../snapshot"
import { Project } from "./project" import { Project } from "./project"
import { Vcs } from "./vcs"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
@@ -16,12 +12,8 @@ export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
ShareNext.init() ShareNext.init()
Format.init()
await LSP.init() await LSP.init()
File.init() File.init()
FileWatcher.init()
Vcs.init()
Snapshot.init()
Bus.subscribe(Command.Event.Executed, async (payload) => { Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) { if (payload.properties.name === Command.Default.INIT) {
+4 -4
View File
@@ -7,13 +7,13 @@ import { Context } from "../util/context"
import { Project } from "./project" import { Project } from "./project"
import { State } from "./state" import { State } from "./state"
export interface Shape { interface Context {
directory: string directory: string
worktree: string worktree: string
project: Project.Info project: Project.Info
} }
const context = Context.create<Shape>("instance") const context = Context.create<Context>("instance")
const cache = new Map<string, Promise<Shape>>() const cache = new Map<string, Promise<Context>>()
const disposal = { const disposal = {
all: undefined as Promise<void> | undefined, all: undefined as Promise<void> | undefined,
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
}) })
} }
function track(directory: string, next: Promise<Shape>) { function track(directory: string, next: Promise<Context>) {
const task = next.catch((error) => { const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory) if (cache.get(directory) === task) cache.delete(directory)
throw error throw error
+33 -61
View File
@@ -1,8 +1,7 @@
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceContext } from "@/effect/instance-context"
import { makeRunPromise } from "@/effect/run-service"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { git } from "@/util/git" import { git } from "@/util/git"
@@ -31,81 +30,54 @@ export namespace Vcs {
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
export interface Interface { export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined> readonly branch: () => Effect.Effect<string | undefined>
} }
interface State {
current: string | undefined
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make<State>( const instance = yield* InstanceContext
Effect.fn("Vcs.state")((ctx) => let currentBranch: string | undefined
Effect.gen(function* () {
if (ctx.project.vcs !== "git") {
return { current: undefined }
}
const getCurrentBranch = async () => { if (instance.project.vcs === "git") {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { const getCurrentBranch = async () => {
cwd: ctx.worktree, const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
}) cwd: instance.project.worktree,
if (result.exitCode !== 0) return undefined })
const text = result.text().trim() if (result.exitCode !== 0) return undefined
return text || undefined const text = result.text().trim()
} return text || undefined
}
const value = { currentBranch = yield* Effect.promise(() => getCurrentBranch())
current: yield* Effect.promise(() => getCurrentBranch()), log.info("initialized", { branch: currentBranch })
}
log.info("initialized", { branch: value.current })
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
Effect.sync(() => Effect.sync(() =>
Bus.subscribe( Bus.subscribe(
FileWatcher.Event.Updated, FileWatcher.Event.Updated,
Instance.bind(async (evt) => { Instance.bind(async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return if (!evt.properties.file.endsWith("HEAD")) return
const next = await getCurrentBranch() const next = await getCurrentBranch()
if (next !== value.current) { if (next !== currentBranch) {
log.info("branch changed", { from: value.current, to: next }) log.info("branch changed", { from: currentBranch, to: next })
value.current = next currentBranch = next
Bus.publish(Event.BranchUpdated, { branch: next }) Bus.publish(Event.BranchUpdated, { branch: next })
} }
}), }),
), ),
), ),
(unsubscribe) => Effect.sync(unsubscribe), (unsubscribe) => Effect.sync(unsubscribe),
) )
}
return value
}),
),
)
return Service.of({ return Service.of({
init: Effect.fn("Vcs.init")(function* () {
yield* InstanceState.get(state)
}),
branch: Effect.fn("Vcs.branch")(function* () { branch: Effect.fn("Vcs.branch")(function* () {
return yield* InstanceState.use(state, (x) => x.current) return currentBranch
}), }),
}) })
}), }),
) )
const runPromise = makeRunPromise(Service, layer)
export function init() {
return runPromise((svc) => svc.init())
}
export function branch() {
return runPromise((svc) => svc.branch())
}
} }
+36 -48
View File
@@ -1,11 +1,10 @@
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth" import * as Auth from "@/auth/effect"
import { InstanceState } from "@/effect/instance-state" import { runPromiseInstance } from "@/effect/runtime"
import { makeRunPromise } from "@/effect/run-service" import { fn } from "@/util/fn"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect" import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
import z from "zod" import z from "zod"
export namespace ProviderAuth { export namespace ProviderAuth {
@@ -92,8 +91,6 @@ export namespace ProviderAuth {
| InstanceType<typeof OauthCallbackFailed> | InstanceType<typeof OauthCallbackFailed>
| InstanceType<typeof ValidationFailed> | InstanceType<typeof ValidationFailed>
type Hook = NonNullable<Hooks["auth"]>
export interface Interface { export interface Interface {
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>> readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
readonly authorize: (input: { readonly authorize: (input: {
@@ -104,37 +101,26 @@ export namespace ProviderAuth {
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error> readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
} }
interface State {
hooks: Record<ProviderID, Hook>
pending: Map<ProviderID, AuthOuathResult>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const auth = yield* Auth.Service const auth = yield* Auth.AuthEffect.Service
const state = yield* InstanceState.make<State>( const hooks = yield* Effect.promise(async () => {
Effect.fn("ProviderAuth.state")(() => const mod = await import("../plugin")
Effect.promise(async () => { const plugins = await mod.Plugin.list()
const plugins = await Plugin.list() return Record.fromEntries(
return { Arr.filterMap(plugins, (x) =>
hooks: Record.fromEntries( x.auth?.provider !== undefined
Arr.filterMap(plugins, (x) => ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
x.auth?.provider !== undefined : Result.failVoid,
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) ),
: Result.failVoid, )
), })
), const pending = new Map<ProviderID, AuthOuathResult>()
pending: new Map<ProviderID, AuthOuathResult>(),
}
}),
),
)
const methods = Effect.fn("ProviderAuth.methods")(function* () { const methods = Effect.fn("ProviderAuth.methods")(function* () {
const hooks = (yield* InstanceState.get(state)).hooks
return Record.map(hooks, (item) => return Record.map(hooks, (item) =>
item.methods.map( item.methods.map(
(method): Method => ({ (method): Method => ({
@@ -168,7 +154,6 @@ export namespace ProviderAuth {
method: number method: number
inputs?: Record<string, string> inputs?: Record<string, string>
}) { }) {
const { hooks, pending } = yield* InstanceState.get(state)
const method = hooks[input.providerID].methods[input.method] const method = hooks[input.providerID].methods[input.method]
if (method.type !== "oauth") return if (method.type !== "oauth") return
@@ -195,7 +180,6 @@ export namespace ProviderAuth {
method: number method: number
code?: string code?: string
}) { }) {
const pending = (yield* InstanceState.get(state)).pending
const match = pending.get(input.providerID) const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) { if (match.method === "code" && !input.code) {
@@ -229,23 +213,27 @@ export namespace ProviderAuth {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer)) export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
const runPromise = makeRunPromise(Service, defaultLayer)
export async function methods() { export async function methods() {
return runPromise((svc) => svc.methods()) return runPromiseInstance(Service.use((svc) => svc.methods()))
} }
export async function authorize(input: { export const authorize = fn(
providerID: ProviderID z.object({
method: number providerID: ProviderID.zod,
inputs?: Record<string, string> method: z.number(),
}): Promise<Authorization | undefined> { inputs: z.record(z.string(), z.string()).optional(),
return runPromise((svc) => svc.authorize(input)) }),
} async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
)
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) { export const callback = fn(
return runPromise((svc) => svc.callback(input)) z.object({
} providerID: ProviderID.zod,
method: z.number(),
code: z.string().optional(),
}),
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
)
} }
+14 -123
View File
@@ -40,12 +40,7 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai" import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity" import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel" import { createVercel } from "@ai-sdk/vercel"
import { import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
createGitLab,
VERSION as GITLAB_PROVIDER_VERSION,
isWorkflowModel,
discoverWorkflowModels,
} from "gitlab-ai-provider"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { GoogleAuth } from "google-auth-library" import { GoogleAuth } from "google-auth-library"
import { ProviderTransform } from "./transform" import { ProviderTransform } from "./transform"
@@ -129,20 +124,18 @@ export namespace Provider {
"@ai-sdk/togetherai": createTogetherAI, "@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity, "@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel, "@ai-sdk/vercel": createVercel,
"gitlab-ai-provider": createGitLab, "@gitlab/gitlab-ai-provider": createGitLab,
// @ts-ignore (TODO: kill this code so we dont have to maintain it) // @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
} }
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any> type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
type CustomVarsLoader = (options: Record<string, any>) => Record<string, string> type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
type CustomDiscoverModels = () => Promise<Record<string, Model>>
type CustomLoader = (provider: Info) => Promise<{ type CustomLoader = (provider: Info) => Promise<{
autoload: boolean autoload: boolean
getModel?: CustomModelLoader getModel?: CustomModelLoader
vars?: CustomVarsLoader vars?: CustomVarsLoader
options?: Record<string, any> options?: Record<string, any>
discoverModels?: CustomDiscoverModels
}> }>
function useLanguageModel(sdk: any) { function useLanguageModel(sdk: any) {
@@ -191,15 +184,6 @@ export namespace Provider {
options: {}, options: {},
} }
}, },
xai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
},
options: {},
}
},
"github-copilot": async () => { "github-copilot": async () => {
return { return {
autoload: false, autoload: false,
@@ -540,105 +524,28 @@ export namespace Provider {
...(providerConfig?.options?.aiGatewayHeaders || {}), ...(providerConfig?.options?.aiGatewayHeaders || {}),
} }
const featureFlags = {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
}
return { return {
autoload: !!apiKey, autoload: !!apiKey,
options: { options: {
instanceUrl, instanceUrl,
apiKey, apiKey,
aiGatewayHeaders, aiGatewayHeaders,
featureFlags, featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
}, },
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) { async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
if (modelID.startsWith("duo-workflow-")) {
const workflowRef = options?.workflowRef as string | undefined
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
const model = sdk.workflowChat(sdkModelID, {
featureFlags,
})
if (workflowRef) {
model.selectedModelRef = workflowRef
}
return model
}
return sdk.agenticChat(modelID, { return sdk.agenticChat(modelID, {
aiGatewayHeaders, aiGatewayHeaders,
featureFlags, featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
}) })
}, },
async discoverModels(): Promise<Record<string, Model>> {
if (!apiKey) {
log.info("gitlab model discovery skipped: no apiKey")
return {}
}
try {
const token = apiKey
const getHeaders = (): Record<string, string> =>
auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
log.info("gitlab model discovery starting", { instanceUrl })
const result = await discoverWorkflowModels(
{ instanceUrl, getHeaders },
{ workingDirectory: Instance.directory },
)
if (!result.models.length) {
log.info("gitlab model discovery skipped: no models found", {
project: result.project ? { id: result.project.id, path: result.project.pathWithNamespace } : null,
})
return {}
}
const models: Record<string, Model> = {}
for (const m of result.models) {
if (!input.models[m.id]) {
models[m.id] = {
id: ModelID.make(m.id),
providerID: ProviderID.make("gitlab"),
name: `Agent Platform (${m.name})`,
family: "",
api: {
id: m.id,
url: instanceUrl,
npm: "gitlab-ai-provider",
},
status: "active",
headers: {},
options: { workflowRef: m.ref },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: m.context, output: m.output },
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
release_date: "",
variants: {},
}
}
}
log.info("gitlab model discovery complete", {
count: Object.keys(models).length,
models: Object.keys(models),
})
return models
} catch (e) {
log.warn("gitlab model discovery failed", { error: e })
return {}
}
},
} }
}, },
"cloudflare-workers-ai": async (input) => { "cloudflare-workers-ai": async (input) => {
@@ -937,9 +844,6 @@ export namespace Provider {
const varsLoaders: { const varsLoaders: {
[providerID: string]: CustomVarsLoader [providerID: string]: CustomVarsLoader
} = {} } = {}
const discoveryLoaders: {
[providerID: string]: CustomDiscoverModels
} = {}
const sdk = new Map<string, SDK>() const sdk = new Map<string, SDK>()
log.info("init") log.info("init")
@@ -1096,7 +1000,6 @@ export namespace Provider {
if (result && (result.autoload || providers[providerID])) { if (result && (result.autoload || providers[providerID])) {
if (result.getModel) modelLoaders[providerID] = result.getModel if (result.getModel) modelLoaders[providerID] = result.getModel
if (result.vars) varsLoaders[providerID] = result.vars if (result.vars) varsLoaders[providerID] = result.vars
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
const opts = result.options ?? {} const opts = result.options ?? {}
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts } const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
mergeProvider(providerID, patch) mergeProvider(providerID, patch)
@@ -1158,18 +1061,6 @@ export namespace Provider {
log.info("found", { providerID }) log.info("found", { providerID })
} }
const gitlab = ProviderID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab]) {
await (async () => {
const discovered = await discoveryLoaders[gitlab]()
for (const [modelID, model] of Object.entries(discovered)) {
if (!providers[gitlab].models[modelID]) {
providers[gitlab].models[modelID] = model
}
}
})().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e }))
}
return { return {
models: languages, models: languages,
providers, providers,
@@ -1350,7 +1241,7 @@ export namespace Provider {
try { try {
const language = s.modelLoaders[model.providerID] const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options }) ? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
: sdk.languageModel(model.api.id) : sdk.languageModel(model.api.id)
s.models.set(key, language) s.models.set(key, language)
return language return language
+5 -7
View File
@@ -755,13 +755,11 @@ export namespace ProviderTransform {
} }
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
if (input.model.capabilities.reasoning) { result["thinkingConfig"] = {
result["thinkingConfig"] = { includeThoughts: true,
includeThoughts: true, }
} if (input.model.api.id.includes("gemini-3")) {
if (input.model.api.id.includes("gemini-3")) { result["thinkingConfig"]["thinkingLevel"] = "high"
result["thinkingConfig"]["thinkingLevel"] = "high"
}
} }
} }
+10 -11
View File
@@ -23,6 +23,8 @@ export namespace Pty {
close: (code?: number, reason?: string) => void close: (code?: number, reason?: string) => void
} }
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
// WebSocket control frame: 0x00 + UTF-8 JSON. // WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => { const meta = (cursor: number) => {
const json = JSON.stringify({ cursor }) const json = JSON.stringify({ cursor })
@@ -97,9 +99,9 @@ export namespace Pty {
try { try {
session.process.kill() session.process.kill()
} catch {} } catch {}
for (const [key, ws] of session.subscribers.entries()) { for (const [id, ws] of session.subscribers.entries()) {
try { try {
if (ws.data === key) ws.close() if (key(ws) === id) ws.close()
} catch { } catch {
// ignore // ignore
} }
@@ -230,9 +232,9 @@ export namespace Pty {
try { try {
session.process.kill() session.process.kill()
} catch {} } catch {}
for (const [key, ws] of session.subscribers.entries()) { for (const [id, ws] of session.subscribers.entries()) {
try { try {
if (ws.data === key) ws.close() if (key(ws) === id) ws.close()
} catch { } catch {
// ignore // ignore
} }
@@ -263,16 +265,13 @@ export namespace Pty {
} }
log.info("client connected to session", { id }) log.info("client connected to session", { id })
// Use ws.data as the unique key for this connection lifecycle. const sub = key(ws)
// If ws.data is undefined, fallback to ws object.
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
// Optionally cleanup if the key somehow exists session.subscribers.delete(sub)
session.subscribers.delete(connectionKey) session.subscribers.set(sub, ws)
session.subscribers.set(connectionKey, ws)
const cleanup = () => { const cleanup = () => {
session.subscribers.delete(connectionKey) session.subscribers.delete(sub)
} }
const start = session.bufferCursor const start = session.bufferCursor
+11 -39
View File
@@ -1,16 +1,15 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import z from "zod" import z from "zod"
import { QuestionID } from "./schema" import { QuestionID } from "./schema"
export namespace Question { const log = Log.create({ service: "question" })
const log = Log.create({ service: "question" })
export namespace Question {
// Schemas // Schemas
export const Option = z export const Option = z
@@ -87,10 +86,6 @@ export namespace Question {
deferred: Deferred.Deferred<Answer[], RejectedError> deferred: Deferred.Deferred<Answer[], RejectedError>
} }
interface State {
pending: Map<QuestionID, PendingEntry>
}
// Service // Service
export interface Interface { export interface Interface {
@@ -109,31 +104,13 @@ export namespace Question {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make<State>( const pending = new Map<QuestionID, PendingEntry>()
Effect.fn("Question.state")(function* () {
const state = {
pending: new Map<QuestionID, PendingEntry>(),
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
}
state.pending.clear()
}),
)
return state
}),
)
const ask = Effect.fn("Question.ask")(function* (input: { const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID sessionID: SessionID
questions: Info[] questions: Info[]
tool?: { messageID: MessageID; callID: string } tool?: { messageID: MessageID; callID: string }
}) { }) {
const pending = (yield* InstanceState.get(state)).pending
const id = QuestionID.ascending() const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length }) log.info("asking", { id, questions: input.questions.length })
@@ -156,7 +133,6 @@ export namespace Question {
}) })
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID) const existing = pending.get(input.requestID)
if (!existing) { if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID }) log.warn("reply for unknown request", { requestID: input.requestID })
@@ -173,7 +149,6 @@ export namespace Question {
}) })
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) { const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(requestID) const existing = pending.get(requestID)
if (!existing) { if (!existing) {
log.warn("reject for unknown request", { requestID }) log.warn("reject for unknown request", { requestID })
@@ -189,7 +164,6 @@ export namespace Question {
}) })
const list = Effect.fn("Question.list")(function* () { const list = Effect.fn("Question.list")(function* () {
const pending = (yield* InstanceState.get(state)).pending
return Array.from(pending.values(), (x) => x.info) return Array.from(pending.values(), (x) => x.info)
}) })
@@ -197,25 +171,23 @@ export namespace Question {
}), }),
) )
const runPromise = makeRunPromise(Service, layer)
export async function ask(input: { export async function ask(input: {
sessionID: SessionID sessionID: SessionID
questions: Info[] questions: Info[]
tool?: { messageID: MessageID; callID: string } tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> { }): Promise<Answer[]> {
return runPromise((s) => s.ask(input)) return runPromiseInstance(Service.use((svc) => svc.ask(input)))
} }
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) { export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
return runPromise((s) => s.reply(input)) return runPromiseInstance(Service.use((svc) => svc.reply(input)))
} }
export async function reject(requestID: QuestionID) { export async function reject(requestID: QuestionID): Promise<void> {
return runPromise((s) => s.reject(requestID)) return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
} }
export async function list() { export async function list(): Promise<Request[]> {
return runPromise((s) => s.list()) return runPromiseInstance(Service.use((svc) => svc.list()))
} }
} }
@@ -1,7 +1,7 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { Permission } from "@/permission" import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
requestID: PermissionID.zod, requestID: PermissionID.zod,
}), }),
), ),
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })), validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
const json = c.req.valid("json") const json = c.req.valid("json")
await Permission.reply({ await PermissionNext.reply({
requestID: params.requestID, requestID: params.requestID,
reply: json.reply, reply: json.reply,
message: json.message, message: json.message,
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
description: "List of pending permissions", description: "List of pending permissions",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Permission.Request.array()), schema: resolver(PermissionNext.Request.array()),
}, },
}, },
}, },
}, },
}), }),
async (c) => { async (c) => {
const permissions = await Permission.list() const permissions = await PermissionNext.list()
return c.json(permissions) return c.json(permissions)
}, },
), ),
@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const projects = Project.list() const projects = await Project.list()
return c.json(projects) return c.json(projects)
}, },
) )
@@ -9,9 +9,6 @@ import { ProviderID } from "../../provider/schema"
import { mapValues } from "remeda" import { mapValues } from "remeda"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Log } from "../../util/log"
const log = Log.create({ service: "server" })
export const ProviderRoutes = lazy(() => export const ProviderRoutes = lazy(() =>
new Hono() new Hono()
+5 -6
View File
@@ -1,15 +1,14 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun" import type { UpgradeWebSocket } from "hono/ws"
import z from "zod" import z from "zod"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema" import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db" import { NotFoundError } from "../../storage/db"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const PtyRoutes = lazy(() => export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
new Hono() return new Hono()
.get( .get(
"/", "/",
describeRoute({ describeRoute({
@@ -197,5 +196,5 @@ export const PtyRoutes = lazy(() =>
}, },
} }
}), }),
), )
) }
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { Permission } from "@/permission" import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error" import { errors } from "../error"
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
permissionID: PermissionID.zod, permissionID: PermissionID.zod,
}), }),
), ),
validator("json", z.object({ response: Permission.Reply })), validator("json", z.object({ response: PermissionNext.Reply })),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
Permission.reply({ PermissionNext.reply({
requestID: params.permissionID, requestID: params.permissionID,
reply: c.req.valid("json").response, reply: c.req.valid("json").response,
}) })
+148 -52
View File
@@ -1,4 +1,7 @@
import { streamSSE } from "hono/streaming"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Bus } from "../bus"
import { BusEvent } from "../bus/bus-event"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
import { cors } from "hono/cors" import { cors } from "hono/cors"
@@ -12,8 +15,9 @@ import { Format } from "../format"
import { TuiRoutes } from "./routes/tui" import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs" import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill" import { Skill } from "../skill/skill"
import { Auth } from "../auth" import { Auth } from "../auth"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Command } from "../command" import { Command } from "../command"
@@ -24,7 +28,7 @@ import { ProviderID } from "../provider/schema"
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware" import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
import { ProjectRoutes } from "./routes/project" import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session" import { SessionRoutes } from "./routes/session"
import { PtyRoutes } from "./routes/pty" // import { PtyRoutes } from "./routes/pty"
import { McpRoutes } from "./routes/mcp" import { McpRoutes } from "./routes/mcp"
import { FileRoutes } from "./routes/file" import { FileRoutes } from "./routes/file"
import { ConfigRoutes } from "./routes/config" import { ConfigRoutes } from "./routes/config"
@@ -34,7 +38,8 @@ import { EventRoutes } from "./routes/event"
import { InstanceBootstrap } from "../project/bootstrap" import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db" import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status" import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun" import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import { HTTPException } from "hono/http-exception" import { HTTPException } from "hono/http-exception"
import { errors } from "./error" import { errors } from "./error"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
@@ -48,13 +53,20 @@ import { lazy } from "@/util/lazy"
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server { export namespace Server {
const log = Log.create({ service: "server" }) export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export const Default = lazy(() => createApp({})) export const Default = lazy(() => create({}).app)
export const createApp = (opts: { cors?: string[] }): Hono => { function create(opts: { cors?: string[] }) {
const log = Log.create({ service: "server" })
const app = new Hono() const app = new Hono()
return app const ws = createNodeWebSocket({ app })
const route = app
.onError((err, c) => { .onError((err, c) => {
log.error("failed", { log.error("failed", {
error: err, error: err,
@@ -151,7 +163,7 @@ export namespace Server {
providerID: ProviderID.zod, providerID: ProviderID.zod,
}), }),
), ),
validator("json", Auth.Info.zod), validator("json", Auth.Info),
async (c) => { async (c) => {
const providerID = c.req.valid("param").providerID const providerID = c.req.valid("param").providerID
const info = c.req.valid("json") const info = c.req.valid("json")
@@ -240,7 +252,6 @@ export namespace Server {
), ),
) )
.route("/project", ProjectRoutes()) .route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes()) .route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes()) .route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes()) .route("/session", SessionRoutes())
@@ -330,7 +341,7 @@ export namespace Server {
}, },
}), }),
async (c) => { async (c) => {
const branch = await Vcs.branch() const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
return c.json({ return c.json({
branch, branch,
}) })
@@ -496,22 +507,70 @@ export namespace Server {
return c.json(await Format.status()) return c.json(await Format.status())
}, },
) )
.all("/*", async (c) => { .get(
const path = c.req.path "/event",
describeRoute({
const response = await proxy(`https://app.opencode.ai${path}`, { summary: "Subscribe to events",
...c.req, description: "Get events",
headers: { operationId: "event.subscribe",
...c.req.raw.headers, responses: {
host: "app.opencode.ai", 200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(BusEvent.payloads()),
},
},
},
}, },
}) }),
response.headers.set( async (c) => {
"Content-Security-Policy", log.info("event connected")
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:", c.header("X-Accel-Buffering", "no")
) c.header("X-Content-Type-Options", "nosniff")
return response return streamSSE(c, async (stream) => {
}) stream.writeSSE({
data: JSON.stringify({
type: "server.connected",
properties: {},
}),
})
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event),
})
if (event.type === Bus.InstanceDisposed.type) {
stream.close()
}
})
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
type: "server.heartbeat",
properties: {},
}),
})
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
unsub()
resolve()
log.info("event disconnected")
})
})
})
},
)
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
return {
app: route as Hono,
ws,
}
} }
export async function openapi() { export async function openapi() {
@@ -529,52 +588,89 @@ export namespace Server {
return result return result
} }
/** @deprecated do not use this dumb shit */
export let url: URL export let url: URL
export function listen(opts: { export async function listen(opts: {
port: number port: number
hostname: string hostname: string
mdns?: boolean mdns?: boolean
mdnsDomain?: string mdnsDomain?: string
cors?: string[] cors?: string[]
}) { }): Promise<Listener> {
url = new URL(`http://${opts.hostname}:${opts.port}`) const log = Log.create({ service: "server" })
const app = createApp(opts) const built = create({
const args = { ...opts,
hostname: opts.hostname, })
idleTimeout: 0, const start = (port: number) =>
fetch: app.fetch, new Promise<ServerType>((resolve, reject) => {
websocket: websocket, const server = createAdaptorServer({ fetch: built.app.fetch })
} as const built.ws.injectWebSocket(server)
const tryServe = (port: number) => { const fail = (err: Error) => {
try { cleanup()
return Bun.serve({ ...args, port }) reject(err)
} catch { }
return undefined const ready = () => {
} cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.once("listening", ready)
server.listen(port, opts.hostname)
})
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
const addr = server.address()
if (!addr || typeof addr === "string") {
throw new Error(`Failed to resolve server address for port ${opts.port}`)
} }
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`) const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(addr.port)
Server.url = url
const shouldPublishMDNS = const shouldPublishMDNS =
opts.mdns && opts.mdns &&
server.port && addr.port &&
opts.hostname !== "127.0.0.1" && opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" && opts.hostname !== "localhost" &&
opts.hostname !== "::1" opts.hostname !== "::1"
if (shouldPublishMDNS) { if (shouldPublishMDNS) {
MDNS.publish(server.port!, opts.mdnsDomain) MDNS.publish(addr.port, opts.mdnsDomain)
} else if (opts.mdns) { } else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
} }
const originalStop = server.stop.bind(server) let closing: Promise<void> | undefined
server.stop = async (closeActiveConnections?: boolean) => { return {
if (shouldPublishMDNS) MDNS.unpublish() hostname: opts.hostname,
return originalStop(closeActiveConnections) port: addr.port,
url,
stop(close?: boolean) {
closing ??= new Promise((resolve, reject) => {
if (shouldPublishMDNS) MDNS.unpublish()
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
if (close) {
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
server.closeAllConnections()
}
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
server.closeIdleConnections()
}
}
})
return closing
},
} }
return server
} }
} }
+4 -4
View File
@@ -28,7 +28,7 @@ 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 { ModelID, ProviderID } from "@/provider/schema"
import { Permission } from "@/permission" import { PermissionNext } 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 { iife } from "@/util/iife" import { iife } from "@/util/iife"
@@ -148,7 +148,7 @@ export namespace Session {
compacting: z.number().optional(), compacting: z.number().optional(),
archived: z.number().optional(), archived: z.number().optional(),
}), }),
permission: Permission.Ruleset.optional(), permission: PermissionNext.Ruleset.optional(),
revert: z revert: z
.object({ .object({
messageID: MessageID.zod, messageID: MessageID.zod,
@@ -300,7 +300,7 @@ export namespace Session {
parentID?: SessionID parentID?: SessionID
workspaceID?: WorkspaceID workspaceID?: WorkspaceID
directory: string directory: string
permission?: Permission.Ruleset permission?: PermissionNext.Ruleset
}) { }) {
const result: Info = { const result: Info = {
id: SessionID.descending(input.id), id: SessionID.descending(input.id),
@@ -423,7 +423,7 @@ export namespace Session {
export const setPermission = fn( export const setPermission = fn(
z.object({ z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
permission: Permission.Ruleset, permission: PermissionNext.Ruleset,
}), }),
async (input) => { async (input) => {
return Database.use((db) => { return Database.use((db) => {

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