mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-20 13:44:27 +00:00
Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1565522352 | ||
|
|
6ea5327002 | ||
|
|
4599154b54 | ||
|
|
b5f2af6c94 | ||
|
|
d0c7a95d3e | ||
|
|
bd8af12df6 | ||
|
|
ce36159683 | ||
|
|
a51e93621e | ||
|
|
612a47c49f | ||
|
|
348e623961 | ||
|
|
714c30548d | ||
|
|
963de44627 | ||
|
|
0bbf26a1ce | ||
|
|
83cdb4de64 | ||
|
|
4989632245 | ||
|
|
d460614cd7 | ||
|
|
b77c797c0f | ||
|
|
7866dbcfcc | ||
|
|
bc43bf378d | ||
|
|
3b669e7f2b | ||
|
|
e48da96886 | ||
|
|
20249bb723 | ||
|
|
e71a21e0a8 | ||
|
|
1071aca91f | ||
|
|
3eeeec359a | ||
|
|
b3d0446d13 | ||
|
|
65e786258a | ||
|
|
816a5f793f | ||
|
|
949191ab74 | ||
|
|
92cd908fb5 | ||
|
|
cbc40a5981 | ||
|
|
b9b210a864 | ||
|
|
d473b7e971 | ||
|
|
6fcc970def | ||
|
|
52a7a04ad8 | ||
|
|
524276604e | ||
|
|
37b8662a9d | ||
|
|
f5783c4313 | ||
|
|
9439a5647e | ||
|
|
2bfe81ee5c | ||
|
|
4c51d2b409 | ||
|
|
fcf1bb010c | ||
|
|
08b6d9c6dc | ||
|
|
0293a8bb80 | ||
|
|
850dbb93eb | ||
|
|
f9f580f0ac | ||
|
|
1d41009b9b | ||
|
|
48e867ee20 | ||
|
|
b5ebc541b9 | ||
|
|
bd7a4cec90 | ||
|
|
63af295a17 | ||
|
|
af69bc1184 | ||
|
|
c619537cbe | ||
|
|
8972c881a8 | ||
|
|
4da1a917d4 | ||
|
|
4710fb4f7a | ||
|
|
386eb03427 | ||
|
|
8a3aa943dd | ||
|
|
9be68a9fa4 | ||
|
|
2a0c9da40b | ||
|
|
81f71c9b30 | ||
|
|
992f4f794a | ||
|
|
0c2b5b2c39 | ||
|
|
009d77c9d8 | ||
|
|
f3cf519d98 | ||
|
|
645c15351b | ||
|
|
f63a2a2636 | ||
|
|
79318eb96a | ||
|
|
eb32c7150e | ||
|
|
c7b93a885c | ||
|
|
df5f873cb3 | ||
|
|
1392d868b1 | ||
|
|
04954a9620 | ||
|
|
fb63fd79a3 | ||
|
|
2e04b66eab | ||
|
|
f0b7c8c374 | ||
|
|
be6f59035a | ||
|
|
27ab51f490 | ||
|
|
bca723e8fe | ||
|
|
1ac39718d8 | ||
|
|
190319fb56 | ||
|
|
3154f0a61c | ||
|
|
0b686b8178 | ||
|
|
4cba56171b | ||
|
|
66342acd31 | ||
|
|
88dae67549 | ||
|
|
0ec42582f3 | ||
|
|
4f82248a68 | ||
|
|
5e069aab97 | ||
|
|
5325b2ec99 | ||
|
|
2a98920922 | ||
|
|
5ea92ea6cb | ||
|
|
a18528a7ee | ||
|
|
ced125a974 | ||
|
|
655fe20beb | ||
|
|
dd0c258e23 | ||
|
|
791e27d289 | ||
|
|
fac0aec69f | ||
|
|
ca26e639f6 | ||
|
|
0b5d54f2cb | ||
|
|
1b408cf06b | ||
|
|
8e102d19ed | ||
|
|
721b2406e9 | ||
|
|
4a6a18cd79 | ||
|
|
c10b5880cc | ||
|
|
e6bf83084c | ||
|
|
6722ee22ee | ||
|
|
870a5731ac | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 | ||
|
|
e993acec31 | ||
|
|
611e616010 | ||
|
|
b286c0ae3f | ||
|
|
81a61f8dbd | ||
|
|
752e449e38 | ||
|
|
5d419a0211 | ||
|
|
8b168981aa | ||
|
|
724dd665ec | ||
|
|
fc258ea74f | ||
|
|
abd9e195ac | ||
|
|
9d78b69cd3 | ||
|
|
e31f00ad22 | ||
|
|
86d7bdc542 | ||
|
|
d3ab78bba0 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d |
28
.github/workflows/test.yml
vendored
28
.github/workflows/test.yml
vendored
@@ -50,20 +50,17 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: unit
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
playwright: bunx playwright install --with-deps
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
playwright: bunx playwright install
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -76,9 +73,28 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install Playwright browsers
|
||||
- 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: ${{ matrix.settings.playwright }}
|
||||
run: bunx playwright install-deps chromium
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Run app e2e tests
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
|
||||
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
|
||||
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
|
||||
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
|
||||
"x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
|
||||
"aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
|
||||
"aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
|
||||
"x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
|
||||
}
|
||||
}
|
||||
|
||||
12
package.json
12
package.json
@@ -4,7 +4,7 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.10",
|
||||
"packageManager": "bun@1.3.11",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@types/bun": "1.3.9",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
@@ -43,8 +43,8 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.35",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
@@ -112,6 +112,8 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectSwitchSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectCloseMenuSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
@@ -23,6 +24,16 @@ import {
|
||||
workspaceMenuTriggerSelector,
|
||||
} 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) {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toBeVisible()
|
||||
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 boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
||||
await assertHealthy(page, context)
|
||||
return button
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
|
||||
export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const button = await waitSidebarButton(page, "openSidebar")
|
||||
await button.click()
|
||||
|
||||
const opened = await expect(button)
|
||||
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const button = await waitSidebarButton(page, "closeSidebar")
|
||||
await button.click()
|
||||
|
||||
const closed = await expect(button)
|
||||
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
await assertHealthy(page, "openSettings")
|
||||
await defocus(page)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
|
||||
|
||||
if (opened) return dialog
|
||||
|
||||
await assertHealthy(page, "openSettings")
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
return dialog
|
||||
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||
|
||||
export async function createTestProject() {
|
||||
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")
|
||||
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
||||
|
||||
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 add -A", { cwd: root, stdio: "ignore" })
|
||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||
@@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
|
||||
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[] = []) {
|
||||
let prev = ""
|
||||
let next = ""
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSlug")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (skip.includes(slug)) return ""
|
||||
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitDir")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
return resolveSlug(slug)
|
||||
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
|
||||
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) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
|
||||
}
|
||||
|
||||
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()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await expect(trigger).toBeVisible()
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||
|
||||
const clicked = await trigger
|
||||
.click({ timeout: 1500 })
|
||||
.click({ force: true, timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import {
|
||||
healthPhase,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
createTestProject,
|
||||
setHealthPhase,
|
||||
seedProjects,
|
||||
sessionIDFromUrl,
|
||||
waitSlug,
|
||||
waitSession,
|
||||
} from "./actions"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
@@ -27,6 +36,29 @@ type 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: [
|
||||
async ({}, use) => {
|
||||
const directory = await getWorktree()
|
||||
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await waitSession(page, { directory, sessionID })
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const root = await createTestProject()
|
||||
const slug = dirSlug(root)
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await waitSession(page, { directory: root, sessionID })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
defocus,
|
||||
@@ -7,43 +6,14 @@ import {
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
sessionIDFromUrl,
|
||||
waitDir,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
@@ -84,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await withProject(
|
||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||
await defocus(page)
|
||||
await workspaces(page, directory, true)
|
||||
await page.reload()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await openSidebar(page)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
@@ -108,8 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click({ force: true })
|
||||
|
||||
await waitSlug(page)
|
||||
await waitDir(page, space)
|
||||
await waitSession(page, { directory: space })
|
||||
|
||||
// Create a session by sending a prompt
|
||||
const prompt = page.locator(promptSelector)
|
||||
@@ -123,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
trackSession(created, space)
|
||||
await waitSessionSaved(space, created)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
@@ -130,15 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
await otherButton.click({ force: true })
|
||||
await waitSession(page, { directory: other })
|
||||
|
||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(rootButton).toBeVisible()
|
||||
await rootButton.click()
|
||||
await rootButton.click({ force: true })
|
||||
|
||||
await waitDir(page, space)
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||
await waitSession(page, { directory: space, sessionID: created })
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitDir,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
@@ -14,20 +23,7 @@ function button(space: { slug: string; raw: string }) {
|
||||
|
||||
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||
await openSidebar(page)
|
||||
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)
|
||||
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
@@ -49,7 +45,8 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
||||
await expect(next).toBeVisible()
|
||||
await next.click({ force: true })
|
||||
|
||||
return waitDir(page, space.directory)
|
||||
await waitSession(page, { directory: space.directory })
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(
|
||||
@@ -57,39 +54,28 @@ async function createSessionFromWorkspace(
|
||||
space: { slug: string; raw: string; directory: string },
|
||||
text: string,
|
||||
) {
|
||||
const next = await openWorkspaceNewSession(page, space)
|
||||
await openWorkspaceNewSession(page, space)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.click()
|
||||
await expect(prompt).toBeFocused()
|
||||
await prompt.fill(text)
|
||||
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 page.keyboard.press("Enter")
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||
const sessionID = sessionIDFromUrl(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 }
|
||||
}
|
||||
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
const info = await createSdk(directory)
|
||||
.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
await waitSessionSaved(space.directory, sessionID)
|
||||
await createSdk(space.directory)
|
||||
.session.abort({ sessionID })
|
||||
.catch(() => undefined)
|
||||
if (!info) return ""
|
||||
return info.directory
|
||||
return sessionID
|
||||
}
|
||||
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
@@ -101,17 +87,8 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
|
||||
trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second)
|
||||
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
trackSession(firstSession.sessionID, 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)
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionIdle,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import {
|
||||
promptAgentSelector,
|
||||
promptModelSelector,
|
||||
@@ -29,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
|
||||
|
||||
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> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as Window & {
|
||||
@@ -44,21 +50,6 @@ 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> {
|
||||
return {
|
||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||
@@ -187,8 +178,7 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
|
||||
|
||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
|
||||
await waitSession(page, { directory, sessionID })
|
||||
}
|
||||
|
||||
async function submit(page: Page, value: string) {
|
||||
@@ -224,7 +214,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||
await waitSession(page, { directory: next.directory })
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -256,9 +246,7 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
||||
await button.click({ force: true })
|
||||
|
||||
const next = await resolveSlug(await waitSlug(page))
|
||||
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
return currentDir(page)
|
||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||
}
|
||||
|
||||
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||
@@ -277,7 +265,7 @@ test("session model and variant restore per session without leaking into new ses
|
||||
await waitUser(directory, first)
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await waitSession(page, { directory, sessionID: first })
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await gotoSession()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -287,6 +287,9 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
|
||||
@@ -378,6 +378,7 @@ function createGlobalSync() {
|
||||
return globalStore.error
|
||||
},
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
bootstrap,
|
||||
updateConfig,
|
||||
project: projectApi,
|
||||
|
||||
@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
|
||||
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) {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cached = metaCache.get(directory)
|
||||
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
|
||||
children,
|
||||
ensureChild,
|
||||
child,
|
||||
peek,
|
||||
projectMeta,
|
||||
projectIcon,
|
||||
mark,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { Component, Show, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import type { E2EWindow } from "@/testing/terminal"
|
||||
|
||||
export type InitError = {
|
||||
name: string
|
||||
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
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() {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
@@ -129,6 +129,16 @@ export default function Layout(props: ParentProps) {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
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 colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||
@@ -137,7 +147,7 @@ export default function Layout(props: ParentProps) {
|
||||
dark: "theme.scheme.dark",
|
||||
}
|
||||
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
||||
const currentDir = createMemo(() => decode64(params.dir) ?? "")
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory,
|
||||
@@ -484,8 +494,8 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const currentSession = params.id
|
||||
if (directory === currentDir() && props.sessionID === currentSession) return
|
||||
if (directory === currentDir() && session?.parentID === currentSession) return
|
||||
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
|
||||
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
|
||||
|
||||
dismissSessionAlert(sessionKey)
|
||||
|
||||
@@ -620,7 +630,7 @@ export default function Layout(props: ParentProps) {
|
||||
const activeDir = currentDir()
|
||||
return workspaceIds(project).filter((directory) => {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = directory === activeDir
|
||||
const active = workspaceKey(directory) === workspaceKey(activeDir)
|
||||
return expanded || active
|
||||
})
|
||||
})
|
||||
@@ -687,7 +697,7 @@ export default function Layout(props: ParentProps) {
|
||||
seen: lru,
|
||||
keep: sessionID,
|
||||
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
||||
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
||||
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -700,7 +710,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
params.dir
|
||||
route()
|
||||
globalSDK.url
|
||||
|
||||
prefetchToken.value += 1
|
||||
@@ -1692,13 +1702,10 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
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
|
||||
return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
|
||||
},
|
||||
([ready, dir, id, root, directory, resolved]) => {
|
||||
if (!ready || !dir || !directory) {
|
||||
([ready, slug, id, root, dir]) => {
|
||||
if (!ready || !slug || !dir) {
|
||||
activeRoute.session = ""
|
||||
activeRoute.sessionProject = ""
|
||||
activeRoute.directory = ""
|
||||
@@ -1712,29 +1719,28 @@ export default function Layout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = resolved || directory
|
||||
const session = `${dir}/${id}`
|
||||
const session = `${slug}/${id}`
|
||||
|
||||
if (!root) {
|
||||
activeRoute.session = session
|
||||
activeRoute.directory = next
|
||||
activeRoute.directory = dir
|
||||
activeRoute.sessionProject = ""
|
||||
return
|
||||
}
|
||||
|
||||
if (server.projects.last() !== root) server.projects.touch(root)
|
||||
|
||||
const changed = session !== activeRoute.session || next !== activeRoute.directory
|
||||
const changed = session !== activeRoute.session || dir !== activeRoute.directory
|
||||
if (changed) {
|
||||
activeRoute.session = session
|
||||
activeRoute.directory = next
|
||||
activeRoute.sessionProject = syncSessionRoute(next, id, root)
|
||||
activeRoute.directory = dir
|
||||
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
|
||||
return
|
||||
}
|
||||
|
||||
if (root === activeRoute.sessionProject) return
|
||||
activeRoute.directory = next
|
||||
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
|
||||
activeRoute.directory = dir
|
||||
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1927,6 +1933,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const projectSidebarCtx: ProjectSidebarContext = {
|
||||
currentDir,
|
||||
currentProject,
|
||||
sidebarOpened: () => layout.sidebar.opened(),
|
||||
sidebarHovering,
|
||||
hoverProject: () => state.hoverProject,
|
||||
|
||||
@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
|
||||
stores.flatMap(roots).sort(sortSessions(now))[0]
|
||||
|
||||
export function hasProjectPermissions<T>(
|
||||
request: Record<string, T[] | undefined>,
|
||||
request: Record<string, T[] | undefined> | undefined,
|
||||
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) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
||||
|
||||
export type ProjectSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
currentProject: Accessor<LocalProject | undefined>
|
||||
sidebarOpened: Accessor<boolean>
|
||||
sidebarHovering: Accessor<boolean>
|
||||
hoverProject: Accessor<string | undefined>
|
||||
@@ -278,11 +279,7 @@ export const SortableProject = (props: {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const selected = createMemo(
|
||||
() =>
|
||||
props.project.worktree === props.ctx.currentDir() ||
|
||||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
|
||||
)
|
||||
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(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 { useLanguage } from "@/context/language"
|
||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||
import { childMapByParent, sortedRootSessions } from "./helpers"
|
||||
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
|
||||
|
||||
type InlineEditorComponent = (props: {
|
||||
id: string
|
||||
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
|
||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
||||
const local = createMemo(() => props.directory === props.project.worktree)
|
||||
const active = createMemo(() => props.ctx.currentDir() === props.directory)
|
||||
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
|
||||
const workspaceValue = createMemo(() => {
|
||||
const branch = workspaceStore.vcs?.branch
|
||||
const name = branch ?? getFilename(props.directory)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
sending: false,
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const fold = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
const next = value.trim()
|
||||
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
header={
|
||||
<>
|
||||
<div
|
||||
data-action="session-question-toggle"
|
||||
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<div data-slot="question-progress" class="ml-auto mr-1">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
jump(i())
|
||||
}}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<IconButton
|
||||
data-action="session-question-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
fold()
|
||||
}}
|
||||
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
class="cursor-default"
|
||||
classList={{
|
||||
"mb-6": store.collapsed && picked() === 0,
|
||||
}}
|
||||
role={store.collapsed ? "button" : undefined}
|
||||
tabIndex={store.collapsed ? 0 : undefined}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (!store.collapsed) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={store.collapsed && picked() > 0}>
|
||||
<div data-slot="question-hint" class="cursor-default mb-6">
|
||||
{picked()} answer{picked() === 1 ? "" : "s"} selected
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.0",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"mysql2": "3.14.4",
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.3.5
|
||||
ARG BUN_VERSION=1.3.11
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#db": {
|
||||
"bun": "./src/storage/db.bun.ts",
|
||||
"node": "./src/storage/db.node.ts",
|
||||
"default": "./src/storage/db.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@effect/language-service": "0.79.0",
|
||||
@@ -51,8 +58,8 @@
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
@@ -83,8 +90,11 @@
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@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/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -97,9 +107,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opentui/core": "0.1.88",
|
||||
"@opentui/solid": "0.1.88",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -115,7 +124,7 @@
|
||||
"cross-spawn": "^7.0.6",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
@@ -146,6 +155,6 @@
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6"
|
||||
"drizzle-orm": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
54
packages/opencode/script/build-node.ts
Normal file
54
packages/opencode/script/build-node.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/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")
|
||||
@@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
Account,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
Info,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
@@ -24,10 +24,30 @@ import {
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
export {
|
||||
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 = {
|
||||
account: Account
|
||||
account: Info
|
||||
orgs: readonly Org[]
|
||||
}
|
||||
|
||||
@@ -108,10 +128,10 @@ const mapAccountServiceError =
|
||||
),
|
||||
)
|
||||
|
||||
export namespace AccountEffect {
|
||||
export namespace Account {
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
||||
readonly list: () => Effect.Effect<Account[], AccountError>
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||
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>
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import { Effect, Option } from "effect"
|
||||
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
type AccountError,
|
||||
type AccessToken,
|
||||
AccountID,
|
||||
AccountEffect,
|
||||
OrgID,
|
||||
} from "./effect"
|
||||
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
|
||||
|
||||
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(AccountEffect.Service.use(f))
|
||||
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(S.Service.use(f))
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(AccountEffect.Service.use(f))
|
||||
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(S.Service.use(f))
|
||||
}
|
||||
|
||||
export namespace Account {
|
||||
export const Account = AccountSchema
|
||||
export type Account = AccountSchema
|
||||
export const Info = Model
|
||||
export type Info = Model
|
||||
|
||||
export function active(): Account | undefined {
|
||||
export function active(): Info | undefined {
|
||||
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
|
||||
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
|
||||
|
||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||
|
||||
@@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
|
||||
|
||||
export namespace AccountRepo {
|
||||
export interface Service {
|
||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
|
||||
readonly list: () => Effect.Effect<Account[], AccountRepoError>
|
||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
|
||||
readonly list: () => Effect.Effect<Info[], AccountRepoError>
|
||||
readonly remove: (accountID: AccountID) => 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>
|
||||
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
||||
AccountRepo,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownSync(Account)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const query = <A>(f: (db: DbClient) => A) =>
|
||||
Effect.try({
|
||||
|
||||
@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
|
||||
)
|
||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||
|
||||
export class Account extends Schema.Class<Account>("Account")({
|
||||
export class Info extends Schema.Class<Info>("Account")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
url: Schema.String,
|
||||
|
||||
@@ -64,6 +64,7 @@ export namespace Agent {
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
||||
@@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||
|
||||
export namespace AuthEffect {
|
||||
export namespace Auth {
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
|
||||
@@ -5,8 +5,8 @@ import * as S from "./effect"
|
||||
|
||||
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||
|
||||
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||
return runtime.runPromise(S.AuthEffect.Service.use(f))
|
||||
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||
return runtime.runPromise(S.Auth.Service.use(f))
|
||||
}
|
||||
|
||||
export namespace Auth {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
import type { ZodObject, ZodRawShape } from "zod"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace BusEvent {
|
||||
@@ -9,7 +9,7 @@ export namespace BusEvent {
|
||||
|
||||
const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
|
||||
const result = {
|
||||
type,
|
||||
properties,
|
||||
|
||||
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
|
||||
event: [
|
||||
{
|
||||
directory?: string
|
||||
payload: any
|
||||
payload: { type: string; properties: Record<string, unknown> }
|
||||
},
|
||||
]
|
||||
}>()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import z from "zod"
|
||||
import { Effect, Layer, PubSub, ServiceMap, Stream } from "effect"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { runCallbackInstance, runPromiseInstance } from "../effect/runtime"
|
||||
|
||||
export namespace Bus {
|
||||
const log = Log.create({ service: "bus" })
|
||||
type Subscription = (event: any) => void
|
||||
|
||||
export const InstanceDisposed = BusEvent.define(
|
||||
"server.instance.disposed",
|
||||
@@ -15,91 +16,130 @@ export namespace Bus {
|
||||
}),
|
||||
)
|
||||
|
||||
const state = Instance.state(
|
||||
() => {
|
||||
const subscriptions = new Map<any, Subscription[]>()
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||
type: D["type"]
|
||||
properties: z.infer<D["properties"]>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: z.output<D["properties"]>,
|
||||
) => Effect.Effect<void>
|
||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const pubsubs = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const wildcardPubSub = yield* PubSub.unbounded<Payload>()
|
||||
|
||||
const getOrCreate = Effect.fnUntraced(function* (type: string) {
|
||||
let ps = pubsubs.get(type)
|
||||
if (!ps) {
|
||||
ps = yield* PubSub.unbounded<Payload>()
|
||||
pubsubs.set(type, ps)
|
||||
}
|
||||
return ps
|
||||
})
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return Effect.gen(function* () {
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = pubsubs.get(def.type)
|
||||
if (ps) yield* PubSub.publish(ps, payload)
|
||||
yield* PubSub.publish(wildcardPubSub, payload)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
async (entry) => {
|
||||
const wildcard = entry.subscriptions.get("*")
|
||||
if (!wildcard) return
|
||||
const event = {
|
||||
type: InstanceDisposed.type,
|
||||
properties: {
|
||||
directory: Instance.directory,
|
||||
},
|
||||
|
||||
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
||||
log.info("subscribing", { type: def.type })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const ps = yield* getOrCreate(def.type)
|
||||
return Stream.fromPubSub(ps) as Stream.Stream<Payload<D>>
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||
}
|
||||
for (const sub of [...wildcard]) {
|
||||
sub(event)
|
||||
|
||||
function subscribeAll(): Stream.Stream<Payload> {
|
||||
log.info("subscribing", { type: "*" })
|
||||
return Stream.fromPubSub(wildcardPubSub).pipe(
|
||||
Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
// Shut down all PubSubs when the layer is torn down.
|
||||
// This causes Stream.fromPubSub consumers to end, triggering
|
||||
// their ensuring/finalizers.
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
log.info("shutting down PubSubs")
|
||||
yield* PubSub.shutdown(wildcardPubSub)
|
||||
for (const ps of pubsubs.values()) {
|
||||
yield* PubSub.shutdown(ps)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({ publish, subscribe, subscribeAll })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function publish<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
properties: z.output<Definition["properties"]>,
|
||||
) {
|
||||
const payload = {
|
||||
type: def.type,
|
||||
properties,
|
||||
}
|
||||
log.info("publishing", {
|
||||
type: def.type,
|
||||
})
|
||||
const pending = []
|
||||
for (const key of [def.type, "*"]) {
|
||||
const match = [...(state().subscriptions.get(key) ?? [])]
|
||||
for (const sub of match) {
|
||||
pending.push(sub(payload))
|
||||
}
|
||||
}
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload,
|
||||
})
|
||||
return Promise.all(pending)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy adapters — plain function API wrapping the Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runStream(stream: (svc: Interface) => Stream.Stream<Payload>, callback: (event: any) => void) {
|
||||
return runCallbackInstance(
|
||||
Service.use((svc) => stream(svc).pipe(Stream.runForEach((msg) => Effect.sync(() => callback(msg))))),
|
||||
)
|
||||
}
|
||||
|
||||
export function subscribe<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
||||
) {
|
||||
return raw(def.type, callback)
|
||||
export function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.publish(def, properties)))
|
||||
}
|
||||
|
||||
export function once<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
callback: (event: {
|
||||
type: Definition["type"]
|
||||
properties: z.infer<Definition["properties"]>
|
||||
}) => "done" | undefined,
|
||||
) {
|
||||
const unsub = subscribe(def, (event) => {
|
||||
if (callback(event)) unsub()
|
||||
})
|
||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => void) {
|
||||
return runStream((svc) => svc.subscribe(def), callback)
|
||||
}
|
||||
|
||||
export function subscribeAll(callback: (event: any) => void) {
|
||||
return raw("*", callback)
|
||||
}
|
||||
const directory = Instance.directory
|
||||
|
||||
function raw(type: string, callback: (event: any) => void) {
|
||||
log.info("subscribing", { type })
|
||||
const subscriptions = state().subscriptions
|
||||
let match = subscriptions.get(type) ?? []
|
||||
match.push(callback)
|
||||
subscriptions.set(type, match)
|
||||
// InstanceDisposed is delivered via GlobalBus because the legacy
|
||||
// adapter's fiber starts asynchronously and may not be running when
|
||||
// disposal happens. In the Effect-native path, forkScoped + scope
|
||||
// closure handles this correctly. This bridge can be removed once
|
||||
// upstream PubSub.shutdown properly wakes suspended subscribers:
|
||||
// https://github.com/Effect-TS/effect-smol/pull/1800
|
||||
const onDispose = (evt: { directory?: string; payload: any }) => {
|
||||
if (evt.payload.type !== InstanceDisposed.type) return
|
||||
if (evt.directory !== directory) return
|
||||
callback(evt.payload)
|
||||
GlobalBus.off("event", onDispose)
|
||||
}
|
||||
GlobalBus.on("event", onDispose)
|
||||
|
||||
const interrupt = runStream((svc) => svc.subscribeAll(), callback)
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
const match = subscriptions.get(type)
|
||||
if (!match) return
|
||||
const index = match.indexOf(callback)
|
||||
if (index === -1) return
|
||||
match.splice(index, 1)
|
||||
GlobalBus.off("event", onDispose)
|
||||
interrupt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
|
||||
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
||||
|
||||
const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
const service = yield* AccountEffect.Service
|
||||
const service = yield* Account.Service
|
||||
|
||||
yield* Prompt.intro("Log in")
|
||||
const login = yield* service.login(url)
|
||||
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
})
|
||||
|
||||
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||
const service = yield* AccountEffect.Service
|
||||
const service = yield* Account.Service
|
||||
const accounts = yield* service.list()
|
||||
if (accounts.length === 0) return yield* println("Not logged in")
|
||||
|
||||
@@ -98,7 +98,7 @@ interface OrgChoice {
|
||||
}
|
||||
|
||||
const switchEffect = Effect.fn("switch")(function* () {
|
||||
const service = yield* AccountEffect.Service
|
||||
const service = yield* Account.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("Not logged in")
|
||||
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
|
||||
})
|
||||
|
||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||
const service = yield* AccountEffect.Service
|
||||
const service = yield* Account.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("No accounts found")
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
|
||||
@@ -370,6 +370,11 @@ export const RunCommand = cmd({
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "edit",
|
||||
action: "allow",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
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}`)
|
||||
|
||||
await new Promise(() => {})
|
||||
|
||||
@@ -480,6 +480,7 @@ function App() {
|
||||
{
|
||||
title: "Toggle MCPs",
|
||||
value: "mcp.list",
|
||||
search: "toggle mcps",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "mcps",
|
||||
@@ -555,8 +556,9 @@ function App() {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Toggle appearance",
|
||||
title: mode() === "dark" ? "Light mode" : "Dark mode",
|
||||
value: "theme.switch_mode",
|
||||
search: "toggle appearance",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -595,6 +597,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle debug panel",
|
||||
search: "toggle debug",
|
||||
category: "System",
|
||||
value: "app.debug",
|
||||
onSelect: (dialog) => {
|
||||
@@ -604,6 +607,7 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle console",
|
||||
search: "toggle console",
|
||||
category: "System",
|
||||
value: "app.console",
|
||||
onSelect: (dialog) => {
|
||||
@@ -644,6 +648,7 @@ function App() {
|
||||
{
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
value: "terminal.title.toggle",
|
||||
search: "toggle terminal title",
|
||||
keybind: "terminal_title_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
@@ -659,6 +664,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
value: "app.toggle.animations",
|
||||
search: "toggle animations",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
@@ -668,6 +674,7 @@ function App() {
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
search: "toggle diff wrapping",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
@@ -79,6 +79,7 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -172,6 +173,17 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -934,7 +946,7 @@ export function Prompt(props: PromptProps) {
|
||||
// Normalize line endings at the boundary
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
if (!pastedContent) {
|
||||
command.trigger("prompt.paste")
|
||||
@@ -1010,23 +1022,30 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { useKV } from "./kv"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
@@ -106,6 +107,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
@@ -136,6 +139,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
@@ -451,6 +461,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
|
||||
@@ -47,6 +47,7 @@ export function Home() {
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
search: "toggle tips",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -568,6 +568,7 @@ export function Session() {
|
||||
{
|
||||
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
||||
value: "session.sidebar.toggle",
|
||||
search: "toggle sidebar",
|
||||
keybind: "sidebar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -582,6 +583,7 @@ export function Session() {
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
search: "toggle code concealment",
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -592,6 +594,7 @@ export function Session() {
|
||||
{
|
||||
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
||||
value: "session.toggle.timestamps",
|
||||
search: "toggle timestamps",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timestamps",
|
||||
@@ -605,6 +608,7 @@ export function Session() {
|
||||
{
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
search: "toggle thinking",
|
||||
keybind: "display_thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -619,6 +623,7 @@ export function Session() {
|
||||
{
|
||||
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||
value: "session.toggle.actions",
|
||||
search: "toggle tool details",
|
||||
keybind: "tool_details",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -627,8 +632,9 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle session scrollbar",
|
||||
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
search: "toggle session scrollbar",
|
||||
keybind: "scrollbar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -58,10 +58,10 @@ export const UpgradeCommand = {
|
||||
spinner.stop("Upgrade failed", 1)
|
||||
if (err instanceof Installation.UpgradeFailedError) {
|
||||
// necessary because choco only allows install/upgrade in elevated terminals
|
||||
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
|
||||
if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
|
||||
prompts.log.error("Please run the terminal as Administrator and try again")
|
||||
} else {
|
||||
prompts.log.error(err.data.stderr)
|
||||
prompts.log.error(err.stderr)
|
||||
}
|
||||
} else if (err instanceof Error) prompts.log.error(err.message)
|
||||
prompts.outro("Done")
|
||||
|
||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
@@ -794,7 +794,12 @@ export namespace Config {
|
||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
|
||||
permission_auto_accept_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("Toggle auto-accept mode for permissions"),
|
||||
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAdaptorServer } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
|
||||
}
|
||||
|
||||
export function Listen(opts: { hostname: string; port: number }) {
|
||||
return Bun.serve({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
const server = createAdaptorServer({
|
||||
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()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export namespace Workspace {
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: space.id,
|
||||
payload: event,
|
||||
payload: event as { type: string; properties: Record<string, unknown> },
|
||||
})
|
||||
})
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { Effect, Exit, Fiber, Layer, LayerMap, MutableHashMap, Scope, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { File } from "@/file"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
@@ -16,6 +17,7 @@ import { registerDisposer } from "./instance-registry"
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| Bus.Service
|
||||
| Question.Service
|
||||
| PermissionNext.Service
|
||||
| ProviderAuth.Service
|
||||
@@ -36,6 +38,7 @@ export type InstanceServices =
|
||||
function lookup(_key: string) {
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(Bus.layer),
|
||||
Layer.fresh(Question.layer),
|
||||
Layer.fresh(PermissionNext.layer),
|
||||
Layer.fresh(ProviderAuth.defaultLayer),
|
||||
@@ -56,7 +59,23 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||
|
||||
// Force-invalidate closes the RcMap entry scope even when refCount > 0.
|
||||
// Standard RcMap.invalidate bails in that case, leaving long-running
|
||||
// consumer fibers orphaned. This is an upstream issue:
|
||||
// https://github.com/Effect-TS/effect-smol/pull/1799
|
||||
const forceInvalidate = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const rcMap = layerMap.rcMap
|
||||
if (rcMap.state._tag === "Closed") return
|
||||
const entry = MutableHashMap.get(rcMap.state.map, directory)
|
||||
if (entry._tag === "None") return
|
||||
MutableHashMap.remove(rcMap.state.map, directory)
|
||||
if (entry.value.fiber) yield* Fiber.interrupt(entry.value.fiber)
|
||||
yield* Scope.close(entry.value.scope, Exit.void)
|
||||
}).pipe(Effect.uninterruptible, Effect.ignore)
|
||||
|
||||
const unregister = registerDisposer((directory) => Effect.runPromise(forceInvalidate(directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||
return Instances.of(layerMap)
|
||||
}),
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { AccountEffect } from "@/account/effect"
|
||||
import { AuthEffect } from "@/auth/effect"
|
||||
import { Account } from "@/account/effect"
|
||||
import { Auth } from "@/auth/effect"
|
||||
import { Instances } from "@/effect/instances"
|
||||
import type { InstanceServices } from "@/effect/instances"
|
||||
import { TruncateEffect } from "@/tool/truncate-effect"
|
||||
import { Installation } from "@/installation"
|
||||
import { Truncate } from "@/tool/truncate-effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
AccountEffect.defaultLayer, //
|
||||
TruncateEffect.defaultLayer,
|
||||
Account.defaultLayer, //
|
||||
Installation.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Instances.layer,
|
||||
).pipe(Layer.provideMerge(AuthEffect.layer)),
|
||||
).pipe(Layer.provideMerge(Auth.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 runCallbackInstance<A, E>(
|
||||
effect: Effect.Effect<A, E, InstanceServices>,
|
||||
): (interruptor?: number) => void {
|
||||
return runtime.runCallback(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||
}
|
||||
|
||||
export function disposeRuntime() {
|
||||
return runtime.dispose()
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ export const prettier: Info = {
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
|
||||
return [await Npm.which("prettier"), "--write", "$FILE"]
|
||||
const bin = await Npm.which("prettier").catch(() => null)
|
||||
if (!bin) return false
|
||||
return [bin, "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -4,9 +4,7 @@ import { InstanceContext } from "@/effect/instance-context"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Config } from "../config/config"
|
||||
import { File } from "../file"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { Log } from "../util/log"
|
||||
@@ -27,6 +25,7 @@ export namespace Format {
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (filepath: string) => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
}
|
||||
|
||||
@@ -99,48 +98,44 @@ export namespace Format {
|
||||
return result
|
||||
}
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
const run = Effect.fn("Format.run")(function* (filepath: string) {
|
||||
log.info("formatting", { file: filepath })
|
||||
const ext = path.extname(filepath)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
||||
log.info("running", { command: item.command })
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", filepath)),
|
||||
{
|
||||
cwd: instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
},
|
||||
catch: (error) => {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file: filepath,
|
||||
})
|
||||
return error
|
||||
},
|
||||
}).pipe(Effect.ignore)
|
||||
}
|
||||
})
|
||||
|
||||
log.info("init")
|
||||
|
||||
const status = Effect.fn("Format.status")(function* () {
|
||||
@@ -156,10 +151,14 @@ export namespace Format {
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({ status })
|
||||
return Service.of({ run, status })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function run(filepath: string) {
|
||||
return runPromiseInstance(Service.use((s) => s.run(filepath)))
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(Service.use((s) => s.status()))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export namespace Global {
|
||||
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
||||
},
|
||||
data,
|
||||
bin: path.join(data, "bin"),
|
||||
bin: path.join(cache, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
cache,
|
||||
config,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Log } from "../util/log"
|
||||
import { iife } from "@/util/iife"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Process } from "@/util/process"
|
||||
import { buffer } from "node:stream/consumers"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_VERSION: string
|
||||
@@ -16,39 +17,7 @@ declare global {
|
||||
export namespace Installation {
|
||||
const log = Log.create({ service: "installation" })
|
||||
|
||||
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 type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
@@ -75,12 +44,9 @@ export namespace Installation {
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export async function info() {
|
||||
return {
|
||||
version: VERSION,
|
||||
latest: await latest(),
|
||||
}
|
||||
}
|
||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
||||
|
||||
export function isPreview() {
|
||||
return CHANNEL !== "latest"
|
||||
@@ -90,214 +56,306 @@ export namespace Installation {
|
||||
return CHANNEL === "local"
|
||||
}
|
||||
|
||||
export async function method() {
|
||||
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()
|
||||
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
||||
stderr: Schema.String,
|
||||
}) {}
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: "npm" as const,
|
||||
command: () => text(["npm", "list", "-g", "--depth=0"]),
|
||||
},
|
||||
{
|
||||
name: "yarn" as const,
|
||||
command: () => text(["yarn", "global", "list"]),
|
||||
},
|
||||
{
|
||||
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"]),
|
||||
},
|
||||
]
|
||||
// Response schemas for external version APIs
|
||||
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
||||
const NpmPackage = Schema.Struct({ version: Schema.String })
|
||||
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
||||
const BrewInfoV2 = Schema.Struct({
|
||||
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
|
||||
})
|
||||
const ChocoPackage = Schema.Struct({
|
||||
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
|
||||
})
|
||||
const ScoopManifest = NpmPackage
|
||||
|
||||
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 = 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 interface Interface {
|
||||
readonly info: () => Effect.Effect<Info>
|
||||
readonly method: () => Effect.Effect<Method>
|
||||
readonly latest: (method?: Method) => Effect.Effect<string>
|
||||
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
|
||||
}
|
||||
|
||||
export const UpgradeFailedError = NamedError.create(
|
||||
"UpgradeFailedError",
|
||||
z.object({
|
||||
stderr: z.string(),
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
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:
|
||||
throw new Error(`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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
async function getBrewFormula() {
|
||||
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
|
||||
if (coreFormula.includes("opencode")) return "opencode"
|
||||
return "opencode"
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeChildProcessSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
// Legacy adapters — dynamic import avoids circular dependency since
|
||||
// foundational modules (db.ts, provider/models.ts) import Installation
|
||||
// at load time, and runtime transitively loads those same modules.
|
||||
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
|
||||
const { runtime } = await import("@/effect/runtime")
|
||||
return runtime.runPromise(Service.use(f))
|
||||
}
|
||||
|
||||
export async function upgrade(method: Method, target: string) {
|
||||
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 function info(): Promise<Info> {
|
||||
return runPromise((svc) => svc.info())
|
||||
}
|
||||
|
||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
||||
export function method(): Promise<Method> {
|
||||
return runPromise((svc) => svc.method())
|
||||
}
|
||||
|
||||
export async function latest(installMethod?: Method) {
|
||||
const detectedMethod = installMethod || (await method())
|
||||
export function latest(installMethod?: Method): Promise<string> {
|
||||
return runPromise((svc) => svc.latest(installMethod))
|
||||
}
|
||||
|
||||
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/, ""))
|
||||
export function upgrade(m: Method, target: string): Promise<void> {
|
||||
return runPromise((svc) => svc.upgrade(m, target))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod/v4"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -166,14 +167,10 @@ export namespace MCP {
|
||||
const queue = [pid]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
|
||||
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
|
||||
() => [-1, ""] as const,
|
||||
)
|
||||
if (code !== 0) continue
|
||||
for (const tok of out.trim().split(/\s+/)) {
|
||||
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
|
||||
for (const tok of lines) {
|
||||
const cpid = parseInt(tok, 10)
|
||||
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
|
||||
if (!isNaN(cpid) && !pids.includes(cpid)) {
|
||||
pids.push(cpid)
|
||||
queue.push(cpid)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
@@ -52,11 +53,74 @@ interface PendingAuth {
|
||||
}
|
||||
|
||||
export namespace McpOAuthCallback {
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
let server: ReturnType<typeof createServer> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
|
||||
return
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return new Response(HTML_ERROR("No authorization code provided"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
},
|
||||
server = createServer(handleRequest)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
if (server) {
|
||||
server.stop()
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||
server = undefined
|
||||
log.info("oauth callback server stopped")
|
||||
}
|
||||
|
||||
1
packages/opencode/src/node.ts
Normal file
1
packages/opencode/src/node.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Server } from "./server/server"
|
||||
@@ -159,11 +159,11 @@ export namespace Npm {
|
||||
path.join(dir, "node_modules", pkg, "package.json"),
|
||||
).catch(() => undefined)
|
||||
if (pkgJson?.bin) {
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
const bin = pkgJson.bin
|
||||
if (typeof bin === "string") return path.basename(bin)
|
||||
if (typeof bin === "string") return unscoped
|
||||
const keys = Object.keys(bin)
|
||||
if (keys.length === 1) return keys[0]
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
return bin[unscoped] ? unscoped : keys[0]
|
||||
}
|
||||
return files[0]
|
||||
|
||||
15
packages/opencode/src/permission/evaluate.ts
Normal file
15
packages/opencode/src/permission/evaluate.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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: "*" }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { evaluate as evalRule } from "./evaluate"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
export namespace PermissionNext {
|
||||
@@ -125,12 +126,8 @@ export namespace PermissionNext {
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
const rules = rulesets.flat()
|
||||
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: "*" }
|
||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||
return evalRule(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||
|
||||
@@ -6,6 +6,7 @@ import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -241,7 +242,7 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
@@ -249,77 +250,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.stop()
|
||||
oauthServer.close(() => {
|
||||
log.info("codex oauth server stopped")
|
||||
})
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ export namespace ProviderAuth {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.AuthEffect.Service
|
||||
const auth = yield* Auth.Auth.Service
|
||||
const hooks = yield* Effect.promise(async () => {
|
||||
const mod = await import("../plugin")
|
||||
const plugins = await mod.Plugin.list()
|
||||
@@ -213,7 +213,7 @@ export namespace ProviderAuth {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
|
||||
|
||||
export async function methods() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.methods()))
|
||||
|
||||
@@ -184,6 +184,15 @@ export namespace Provider {
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
xai: async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
return sdk.responses(modelID)
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
"github-copilot": async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
|
||||
@@ -23,6 +23,8 @@ export namespace Pty {
|
||||
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.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
@@ -97,9 +99,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -230,9 +232,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -263,16 +265,13 @@ export namespace Pty {
|
||||
}
|
||||
log.info("client connected to session", { id })
|
||||
|
||||
// Use ws.data as the unique key for this connection lifecycle.
|
||||
// If ws.data is undefined, fallback to ws object.
|
||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
||||
const sub = key(ws)
|
||||
|
||||
// Optionally cleanup if the key somehow exists
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.set(connectionKey, ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
|
||||
const cleanup = () => {
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.delete(sub)
|
||||
}
|
||||
|
||||
const start = session.bufferCursor
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const projects = await Project.list()
|
||||
const projects = Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const PtyRoutes = lazy(() =>
|
||||
new Hono()
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
@@ -197,5 +196,5 @@ export const PtyRoutes = lazy(() =>
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { streamSSE } from "hono/streaming"
|
||||
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 { Hono } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
@@ -25,7 +28,7 @@ import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
||||
import { ProjectRoutes } from "./routes/project"
|
||||
import { SessionRoutes } from "./routes/session"
|
||||
import { PtyRoutes } from "./routes/pty"
|
||||
// import { PtyRoutes } from "./routes/pty"
|
||||
import { McpRoutes } from "./routes/mcp"
|
||||
import { FileRoutes } from "./routes/file"
|
||||
import { ConfigRoutes } from "./routes/config"
|
||||
@@ -35,7 +38,8 @@ import { EventRoutes } from "./routes/event"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
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 { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -49,13 +53,20 @@ import { lazy } from "@/util/lazy"
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
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()
|
||||
return app
|
||||
const ws = createNodeWebSocket({ app })
|
||||
const route = app
|
||||
.onError((err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
@@ -241,7 +252,6 @@ export namespace Server {
|
||||
),
|
||||
)
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes())
|
||||
.route("/config", ConfigRoutes())
|
||||
.route("/experimental", ExperimentalRoutes())
|
||||
.route("/session", SessionRoutes())
|
||||
@@ -497,22 +507,70 @@ export namespace Server {
|
||||
return c.json(await Format.status())
|
||||
},
|
||||
)
|
||||
.all("/*", async (c) => {
|
||||
const path = c.req.path
|
||||
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to events",
|
||||
description: "Get events",
|
||||
operationId: "event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(BusEvent.payloads()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"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:",
|
||||
)
|
||||
return response
|
||||
})
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
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() {
|
||||
@@ -530,52 +588,89 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
/** @deprecated do not use this dumb shit */
|
||||
export let url: URL
|
||||
|
||||
export function listen(opts: {
|
||||
export async function listen(opts: {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
}) {
|
||||
url = new URL(`http://${opts.hostname}:${opts.port}`)
|
||||
const app = createApp(opts)
|
||||
const args = {
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: websocket,
|
||||
} as const
|
||||
const tryServe = (port: number) => {
|
||||
try {
|
||||
return Bun.serve({ ...args, port })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}): Promise<Listener> {
|
||||
const log = Log.create({ service: "server" })
|
||||
const built = create({
|
||||
...opts,
|
||||
})
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||
built.ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
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 =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
addr.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (shouldPublishMDNS) {
|
||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
const originalStop = server.stop.bind(server)
|
||||
server.stop = async (closeActiveConnections?: boolean) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
return originalStop(closeActiveConnections)
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { STATUS_CODES } from "http"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { type SystemError } from "bun"
|
||||
import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ import { MCP } from "../mcp"
|
||||
import { LSP } from "../lsp"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import { FileTime } from "../file/time"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { Command } from "../command"
|
||||
import { $ } from "bun"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
@@ -48,6 +48,7 @@ import { iife } from "@/util/iife"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -318,11 +319,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
||||
if (
|
||||
lastAssistant?.finish &&
|
||||
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
|
||||
lastUser.id < lastAssistant.id
|
||||
) {
|
||||
if (shouldExitLoop(lastUser, lastAssistant)) {
|
||||
log.info("exiting loop", { sessionID })
|
||||
break
|
||||
}
|
||||
@@ -1812,15 +1809,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
template = template + "\n\n" + input.arguments
|
||||
}
|
||||
|
||||
const shell = ConfigMarkdown.shell(template)
|
||||
if (shell.length > 0) {
|
||||
const shellMatches = ConfigMarkdown.shell(template)
|
||||
if (shellMatches.length > 0) {
|
||||
const sh = Shell.preferred()
|
||||
const results = await Promise.all(
|
||||
shell.map(async ([, cmd]) => {
|
||||
try {
|
||||
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
|
||||
} catch (error) {
|
||||
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
shellMatches.map(async ([, cmd]) => {
|
||||
const out = await Process.text([cmd], { shell: sh, nothrow: true })
|
||||
return out.text
|
||||
}),
|
||||
)
|
||||
let index = 0
|
||||
@@ -1990,7 +1985,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (!cleaned) return
|
||||
|
||||
const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
|
||||
return Session.setTitle({ sessionID: input.session.id, title })
|
||||
return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => {
|
||||
if (NotFoundError.isInstance(err)) return
|
||||
throw err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for testing — determines whether the prompt loop should exit */
|
||||
export function shouldExitLoop(
|
||||
lastUser: MessageV2.User | undefined,
|
||||
lastAssistant: MessageV2.Assistant | undefined,
|
||||
): boolean {
|
||||
if (!lastUser) return false
|
||||
if (!lastAssistant?.finish) return false
|
||||
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
|
||||
return lastAssistant.parentID === lastUser.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
|
||||
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Bus } from "@/bus"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
|
||||
export namespace SessionSummary {
|
||||
function unquoteGitPath(input: string) {
|
||||
@@ -73,11 +74,17 @@ export namespace SessionSummary {
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
async (input) => {
|
||||
const all = await Session.messages({ sessionID: input.sessionID })
|
||||
await Promise.all([
|
||||
summarizeSession({ sessionID: input.sessionID, messages: all }),
|
||||
summarizeMessage({ messageID: input.messageID, messages: all }),
|
||||
])
|
||||
await Session.messages({ sessionID: input.sessionID })
|
||||
.then((all) =>
|
||||
Promise.all([
|
||||
summarizeSession({ sessionID: input.sessionID, messages: all }),
|
||||
summarizeMessage({ messageID: input.messageID, messages: all }),
|
||||
]),
|
||||
)
|
||||
.catch((err) => {
|
||||
if (NotFoundError.isInstance(err)) return
|
||||
throw err
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -102,7 +109,8 @@ export namespace SessionSummary {
|
||||
const messages = input.messages.filter(
|
||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
||||
)
|
||||
const msgWithParts = messages.find((m) => m.info.id === input.messageID)!
|
||||
const msgWithParts = messages.find((m) => m.info.id === input.messageID)
|
||||
if (!msgWithParts) return
|
||||
const userMsg = msgWithParts.info as MessageV2.User
|
||||
const diffs = await computeDiff({ messages })
|
||||
userMsg.summary = {
|
||||
|
||||
8
packages/opencode/src/storage/db.bun.ts
Normal file
8
packages/opencode/src/storage/db.bun.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
|
||||
export function init(path: string) {
|
||||
const sqlite = new Database(path, { create: true })
|
||||
const db = drizzle({ client: sqlite })
|
||||
return db
|
||||
}
|
||||
8
packages/opencode/src/storage/db.node.ts
Normal file
8
packages/opencode/src/storage/db.node.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
|
||||
export function init(path: string) {
|
||||
const sqlite = new DatabaseSync(path)
|
||||
const db = drizzle({ client: sqlite })
|
||||
return db
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Database as BunDatabase } from "bun:sqlite"
|
||||
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
|
||||
export * from "drizzle-orm"
|
||||
@@ -11,10 +10,10 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import * as schema from "./schema"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { init } from "#db"
|
||||
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
|
||||
|
||||
@@ -36,17 +35,12 @@ export namespace Database {
|
||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||
})
|
||||
|
||||
type Schema = typeof schema
|
||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
||||
export type Transaction = SQLiteTransaction<"sync", void>
|
||||
|
||||
type Client = SQLiteBunDatabase
|
||||
|
||||
type Journal = { sql: string; timestamp: number; name: string }[]
|
||||
|
||||
const state = {
|
||||
sqlite: undefined as BunDatabase | undefined,
|
||||
}
|
||||
|
||||
function time(tag: string) {
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
|
||||
if (!match) return 0
|
||||
@@ -83,17 +77,14 @@ export namespace Database {
|
||||
export const Client = lazy(() => {
|
||||
log.info("opening database", { path: Path })
|
||||
|
||||
const sqlite = new BunDatabase(Path, { create: true })
|
||||
state.sqlite = sqlite
|
||||
const db = init(Path)
|
||||
|
||||
sqlite.run("PRAGMA journal_mode = WAL")
|
||||
sqlite.run("PRAGMA synchronous = NORMAL")
|
||||
sqlite.run("PRAGMA busy_timeout = 5000")
|
||||
sqlite.run("PRAGMA cache_size = -64000")
|
||||
sqlite.run("PRAGMA foreign_keys = ON")
|
||||
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
db.run("PRAGMA journal_mode = WAL")
|
||||
db.run("PRAGMA synchronous = NORMAL")
|
||||
db.run("PRAGMA busy_timeout = 5000")
|
||||
db.run("PRAGMA cache_size = -64000")
|
||||
db.run("PRAGMA foreign_keys = ON")
|
||||
db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
// Apply schema migrations
|
||||
const entries =
|
||||
@@ -117,14 +108,11 @@ export namespace Database {
|
||||
})
|
||||
|
||||
export function close() {
|
||||
const sqlite = state.sqlite
|
||||
if (!sqlite) return
|
||||
sqlite.close()
|
||||
state.sqlite = undefined
|
||||
Client().$client.close()
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
|
||||
export type TxOrDb = Transaction | Client
|
||||
|
||||
const ctx = Context.create<{
|
||||
tx: TxOrDb
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { trimDiff } from "./edit"
|
||||
import { LSP } from "../lsp"
|
||||
import { Format } from "../format"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import DESCRIPTION from "./apply_patch.txt"
|
||||
import { File } from "../file"
|
||||
@@ -220,6 +221,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
}
|
||||
|
||||
if (edited) {
|
||||
await Format.run(edited)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: edited,
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Bus } from "../bus"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Format } from "../format"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
@@ -71,6 +72,7 @@ export const EditTool = Tool.define("edit", {
|
||||
},
|
||||
})
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Format.run(filePath)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
@@ -108,6 +110,7 @@ export const EditTool = Tool.define("edit", {
|
||||
})
|
||||
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await Format.run(filePath)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace ToolRegistry {
|
||||
if (matches.length) await Config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = await import(pathToFileURL(match).href)
|
||||
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { PermissionNext } from "../permission"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import { ToolID } from "./schema"
|
||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
||||
|
||||
export namespace TruncateEffect {
|
||||
export namespace Truncate {
|
||||
const log = Log.create({ service: "truncation" })
|
||||
const RETENTION = Duration.days(7)
|
||||
|
||||
@@ -28,7 +28,7 @@ export namespace TruncateEffect {
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info) {
|
||||
if (!agent?.permission) return false
|
||||
return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
|
||||
return evaluate("task", "*", agent.permission).action !== "deny"
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { TruncateEffect as S } from "./truncate-effect"
|
||||
import { Truncate as S } from "./truncate-effect"
|
||||
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = S.MAX_LINES
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Format } from "../format"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { trimDiff } from "./edit"
|
||||
@@ -42,6 +43,7 @@ export const WriteTool = Tool.define("write", {
|
||||
})
|
||||
|
||||
await Filesystem.write(filepath, params.content)
|
||||
await Format.run(filepath)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filepath,
|
||||
})
|
||||
|
||||
@@ -61,9 +61,9 @@ export namespace Process {
|
||||
|
||||
const proc = launch(cmd[0], cmd.slice(1), {
|
||||
cwd: opts.cwd,
|
||||
shell: opts.shell,
|
||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||
shell: opts.shell,
|
||||
windowsHide: process.platform === "win32",
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import whichPkg from "which"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
|
||||
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
|
||||
const result = whichPkg.sync(cmd, {
|
||||
nothrow: true,
|
||||
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
|
||||
path: full,
|
||||
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
|
||||
})
|
||||
return typeof result === "string" ? result : null
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { AccountEffect } from "../../src/account/effect"
|
||||
import { Account } from "../../src/account/effect"
|
||||
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||
|
||||
const live = (client: HttpClient.HttpClient) =>
|
||||
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
||||
Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
||||
|
||||
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
||||
HttpClientResponse.fromWeb(
|
||||
@@ -52,7 +52,7 @@ const deviceTokenClient = (body: unknown, status = 400) =>
|
||||
)
|
||||
|
||||
const poll = (body: unknown, status = 400) =>
|
||||
AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
||||
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
||||
|
||||
it.effect("orgsByAccount groups orgs per account", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -97,7 +97,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
||||
const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
||||
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
||||
@@ -135,7 +135,7 @@ it.effect("token refresh persists the new token", () =>
|
||||
),
|
||||
)
|
||||
|
||||
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
||||
const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(Option.getOrThrow(token)).toBeDefined()
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
@@ -178,7 +178,7 @@ it.effect("config sends the selected org header", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
|
||||
const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
|
||||
Effect.provide(live(client)),
|
||||
)
|
||||
|
||||
@@ -209,7 +209,7 @@ it.effect("poll stores the account and first org on success", () =>
|
||||
),
|
||||
)
|
||||
|
||||
const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
|
||||
const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(res._tag).toBe("PollSuccess")
|
||||
if (res._tag === "PollSuccess") {
|
||||
|
||||
@@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => {
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.mode).toBe("primary")
|
||||
expect(build?.native).toBe(true)
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
},
|
||||
})
|
||||
@@ -217,8 +217,8 @@ test("agent permission config merges with defaults", async () => {
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
// Edit still asks (default behavior)
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
372
packages/opencode/test/bus/bus.test.ts
Normal file
372
packages/opencode/test/bus/bus.test.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Deferred, Effect, Stream } from "effect"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test event definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TestEvent = {
|
||||
Ping: BusEvent.define("test.ping", z.object({ value: z.number() })),
|
||||
Pong: BusEvent.define("test.pong", z.object({ message: z.string() })),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||
return Instance.provide({ directory, fn })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Bus", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
describe("publish + subscribe", () => {
|
||||
test("subscriber receives matching events", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
})
|
||||
await Bus.publish(TestEvent.Ping, { value: 42 })
|
||||
await Bus.publish(TestEvent.Ping, { value: 99 })
|
||||
})
|
||||
|
||||
expect(received).toEqual([42, 99])
|
||||
})
|
||||
|
||||
test("subscriber does not receive events of other types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const pings: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
pings.push(evt.properties.value)
|
||||
})
|
||||
await Bus.publish(TestEvent.Pong, { message: "hello" })
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
})
|
||||
|
||||
expect(pings).toEqual([1])
|
||||
})
|
||||
|
||||
test("publish with no subscribers does not throw", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple subscribers", () => {
|
||||
test("all subscribers for same event type are called", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => a.push(evt.properties.value))
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => b.push(evt.properties.value))
|
||||
await Bus.publish(TestEvent.Ping, { value: 7 })
|
||||
})
|
||||
|
||||
expect(a).toEqual([7])
|
||||
expect(b).toEqual([7])
|
||||
})
|
||||
|
||||
test("subscribers are called in registration order", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const order: string[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, () => order.push("first"))
|
||||
Bus.subscribe(TestEvent.Ping, () => order.push("second"))
|
||||
Bus.subscribe(TestEvent.Ping, () => order.push("third"))
|
||||
await Bus.publish(TestEvent.Ping, { value: 0 })
|
||||
})
|
||||
|
||||
expect(order).toEqual(["first", "second", "third"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("unsubscribe", () => {
|
||||
test("unsubscribe stops delivery", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
const unsub = Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
})
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
unsub()
|
||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||
})
|
||||
|
||||
expect(received).toEqual([1])
|
||||
})
|
||||
|
||||
test("unsubscribe is idempotent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
const unsub = Bus.subscribe(TestEvent.Ping, () => {})
|
||||
unsub()
|
||||
unsub() // should not throw
|
||||
})
|
||||
})
|
||||
|
||||
test("unsubscribing one does not affect others", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
const unsubA = Bus.subscribe(TestEvent.Ping, (evt) => a.push(evt.properties.value))
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => b.push(evt.properties.value))
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
unsubA()
|
||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||
})
|
||||
|
||||
expect(a).toEqual([1])
|
||||
expect(b).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("subscribeAll", () => {
|
||||
test("receives events of all types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const all: string[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
all.push(evt.type)
|
||||
})
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
await Bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
})
|
||||
|
||||
expect(all).toEqual(["test.ping", "test.pong"])
|
||||
})
|
||||
|
||||
test("subscribeAll + typed subscribe both fire", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const typed: number[] = []
|
||||
const wild: string[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => typed.push(evt.properties.value))
|
||||
Bus.subscribeAll((evt) => wild.push(evt.type))
|
||||
await Bus.publish(TestEvent.Ping, { value: 5 })
|
||||
})
|
||||
|
||||
expect(typed).toEqual([5])
|
||||
expect(wild).toEqual(["test.ping"])
|
||||
})
|
||||
|
||||
test("unsubscribe from subscribeAll", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const all: string[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
const unsub = Bus.subscribeAll((evt) => all.push(evt.type))
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
unsub()
|
||||
await Bus.publish(TestEvent.Pong, { message: "missed" })
|
||||
})
|
||||
|
||||
expect(all).toEqual(["test.ping"])
|
||||
})
|
||||
|
||||
test("subscribeAll delivers InstanceDisposed on disposal", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const all: string[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribeAll((evt) => {
|
||||
all.push(evt.type)
|
||||
})
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
})
|
||||
|
||||
await Instance.disposeAll()
|
||||
|
||||
expect(all).toContain("test.ping")
|
||||
expect(all).toContain(Bus.InstanceDisposed.type)
|
||||
})
|
||||
|
||||
test("manual unsubscribe suppresses InstanceDisposed", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const all: string[] = []
|
||||
let unsub = () => {}
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
unsub = Bus.subscribeAll((evt) => {
|
||||
all.push(evt.type)
|
||||
})
|
||||
})
|
||||
|
||||
unsub()
|
||||
await Instance.disposeAll()
|
||||
|
||||
expect(all).not.toContain(Bus.InstanceDisposed.type)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GlobalBus forwarding", () => {
|
||||
test("publish emits to GlobalBus with directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const globalEvents: Array<{ directory?: string; payload: any }> = []
|
||||
|
||||
const handler = (evt: any) => globalEvents.push(evt)
|
||||
GlobalBus.on("event", handler)
|
||||
|
||||
try {
|
||||
await withInstance(tmp.path, async () => {
|
||||
await Bus.publish(TestEvent.Ping, { value: 42 })
|
||||
})
|
||||
|
||||
const ping = globalEvents.find((e) => e.payload.type === "test.ping")
|
||||
expect(ping).toBeDefined()
|
||||
expect(ping!.directory).toBe(tmp.path)
|
||||
expect(ping!.payload).toEqual({
|
||||
type: "test.ping",
|
||||
properties: { value: 42 },
|
||||
})
|
||||
} finally {
|
||||
GlobalBus.off("event", handler)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("instance isolation", () => {
|
||||
test("subscribers in one instance do not receive events from another", async () => {
|
||||
await using tmpA = await tmpdir()
|
||||
await using tmpB = await tmpdir()
|
||||
const eventsA: number[] = []
|
||||
const eventsB: number[] = []
|
||||
|
||||
await withInstance(tmpA.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => eventsA.push(evt.properties.value))
|
||||
})
|
||||
|
||||
await withInstance(tmpB.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, (evt) => eventsB.push(evt.properties.value))
|
||||
})
|
||||
|
||||
await withInstance(tmpA.path, async () => {
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
})
|
||||
|
||||
await withInstance(tmpB.path, async () => {
|
||||
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||
})
|
||||
|
||||
expect(eventsA).toEqual([1])
|
||||
expect(eventsB).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe("async subscribers", () => {
|
||||
test("publish is fire-and-forget (does not await subscriber callbacks)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: number[] = []
|
||||
|
||||
await withInstance(tmp.path, async () => {
|
||||
Bus.subscribe(TestEvent.Ping, async (evt) => {
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
received.push(evt.properties.value)
|
||||
})
|
||||
|
||||
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||
// Give the async subscriber time to complete
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
})
|
||||
|
||||
expect(received).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Effect service", () => {
|
||||
test("subscribeAll stream receives published events", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const received: string[] = []
|
||||
|
||||
await withInstance(tmp.path, () =>
|
||||
Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Bus.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
let count = 0
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
svc.subscribeAll().pipe(
|
||||
Stream.runForEach((msg) =>
|
||||
Effect.gen(function* () {
|
||||
received.push(msg.type)
|
||||
if (++count >= 2) yield* Deferred.succeed(done, undefined)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Let the forked fiber start and subscribe to the PubSub
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* svc.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* svc.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done)
|
||||
}),
|
||||
).pipe(Effect.provide(Bus.layer)),
|
||||
),
|
||||
)
|
||||
|
||||
expect(received).toEqual(["test.ping", "test.pong"])
|
||||
})
|
||||
|
||||
test("subscribeAll stream ends with ensuring when scope closes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let ensuringFired = false
|
||||
|
||||
await withInstance(tmp.path, () =>
|
||||
Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Bus.Service
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
svc.subscribeAll().pipe(
|
||||
Stream.runForEach(() => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => {
|
||||
ensuringFired = true
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
yield* svc.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Effect.yieldNow
|
||||
}),
|
||||
).pipe(Effect.provide(Bus.layer)),
|
||||
),
|
||||
)
|
||||
|
||||
expect(ensuringFired).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,9 @@ import path from "path"
|
||||
import { Deferred, Effect, Option } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
/** Run `body` with a live FileWatcher service. */
|
||||
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||
let done = false
|
||||
|
||||
function on(evt: BusUpdate) {
|
||||
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
|
||||
if (done) return
|
||||
if (evt.directory !== directory) return
|
||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
||||
if (!check(evt.payload.properties)) return
|
||||
hit(evt.payload.properties)
|
||||
}
|
||||
if (!check(evt.properties)) return
|
||||
hit(evt.properties)
|
||||
})
|
||||
|
||||
function cleanup() {
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
GlobalBus.off("event", on)
|
||||
unsub()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||
|
||||
@@ -1,47 +1,155 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { Installation } from "../../src/installation"
|
||||
|
||||
const fetch0 = globalThis.fetch
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = fetch0
|
||||
})
|
||||
function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
|
||||
const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
|
||||
return Layer.succeed(HttpClient.HttpClient, client)
|
||||
}
|
||||
|
||||
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
|
||||
const spawner = ChildProcessSpawner.make((command) => {
|
||||
const std = ChildProcess.isStandardCommand(command) ? command : undefined
|
||||
const output = handler(std?.command ?? "", std?.args ?? [])
|
||||
return Effect.succeed(
|
||||
ChildProcessSpawner.makeHandle({
|
||||
pid: ChildProcessSpawner.ProcessId(0),
|
||||
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
|
||||
isRunning: Effect.succeed(false),
|
||||
kill: () => Effect.void,
|
||||
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
|
||||
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
|
||||
stderr: Stream.empty,
|
||||
all: Stream.empty,
|
||||
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
|
||||
getOutputFd: () => Stream.empty,
|
||||
}),
|
||||
)
|
||||
})
|
||||
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
function testLayer(
|
||||
httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
|
||||
spawnHandler?: (cmd: string, args: readonly string[]) => string,
|
||||
) {
|
||||
return Installation.layer.pipe(
|
||||
Layer.provide(mockHttpClient(httpHandler)),
|
||||
Layer.provide(mockSpawner(spawnHandler)),
|
||||
)
|
||||
}
|
||||
|
||||
describe("installation", () => {
|
||||
test("reads release version from GitHub releases", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ tag_name: "v1.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch
|
||||
describe("latest", () => {
|
||||
test("reads release version from GitHub releases", async () => {
|
||||
const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))
|
||||
|
||||
expect(await Installation.latest("unknown")).toBe("1.2.3")
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("1.2.3")
|
||||
})
|
||||
|
||||
test("reads scoop manifest versions", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ version: "2.3.4" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch
|
||||
test("strips v prefix from GitHub release tag", async () => {
|
||||
const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
|
||||
|
||||
expect(await Installation.latest("scoop")).toBe("2.3.4")
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("4.0.0-beta.1")
|
||||
})
|
||||
|
||||
test("reads chocolatey feed versions", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
d: {
|
||||
results: [{ Version: "3.4.5" }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
test("reads npm registry versions", async () => {
|
||||
const layer = testLayer(
|
||||
() => jsonResponse({ version: "1.5.0" }),
|
||||
(cmd, args) => {
|
||||
if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
|
||||
return ""
|
||||
},
|
||||
)) as unknown as typeof fetch
|
||||
)
|
||||
|
||||
expect(await Installation.latest("choco")).toBe("3.4.5")
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("1.5.0")
|
||||
})
|
||||
|
||||
test("reads npm registry versions for bun method", async () => {
|
||||
const layer = testLayer(
|
||||
() => jsonResponse({ version: "1.6.0" }),
|
||||
() => "",
|
||||
)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("1.6.0")
|
||||
})
|
||||
|
||||
test("reads scoop manifest versions", async () => {
|
||||
const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("2.3.4")
|
||||
})
|
||||
|
||||
test("reads chocolatey feed versions", async () => {
|
||||
const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("3.4.5")
|
||||
})
|
||||
|
||||
test("reads brew formulae API versions", async () => {
|
||||
const layer = testLayer(
|
||||
() => jsonResponse({ versions: { stable: "2.0.0" } }),
|
||||
(cmd, args) => {
|
||||
// getBrewFormula: return core formula (no tap)
|
||||
if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
|
||||
if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("2.0.0")
|
||||
})
|
||||
|
||||
test("reads brew tap info JSON via CLI", async () => {
|
||||
const brewInfoJson = JSON.stringify({
|
||||
formulae: [{ versions: { stable: "2.1.0" } }],
|
||||
})
|
||||
const layer = testLayer(
|
||||
() => jsonResponse({}), // HTTP not used for tap formula
|
||||
(cmd, args) => {
|
||||
if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula"))
|
||||
return "opencode"
|
||||
if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("2.1.0")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
85
packages/opencode/test/session/prompt-loop-exit.test.ts
Normal file
85
packages/opencode/test/session/prompt-loop-exit.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
|
||||
function makeUser(id: string): MessageV2.User {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: Date.now() },
|
||||
agent: "default",
|
||||
model: { providerID: "openai", modelID: "gpt-4" },
|
||||
} as MessageV2.User
|
||||
}
|
||||
|
||||
function makeAssistant(
|
||||
id: string,
|
||||
parentID: string,
|
||||
finish?: string,
|
||||
): MessageV2.Assistant {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
mode: "default",
|
||||
agent: "default",
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: "gpt-4",
|
||||
providerID: "openai",
|
||||
time: { created: Date.now() },
|
||||
finish,
|
||||
} as MessageV2.Assistant
|
||||
}
|
||||
|
||||
describe("shouldExitLoop", () => {
|
||||
test("normal case: user ID < assistant ID, parentID matches, finish=end_turn → exits", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
|
||||
})
|
||||
|
||||
test("clock skew: user ID > assistant ID, parentID matches, finish=stop → exits", () => {
|
||||
// Simulates client clock ahead: user message ID sorts AFTER the assistant ID
|
||||
const user = makeUser("01ZZZ")
|
||||
const assistant = makeAssistant("01AAA", "01ZZZ", "stop")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
|
||||
})
|
||||
|
||||
test("unfinished assistant: finish=tool-calls → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01AAA", "tool-calls")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
test("unfinished assistant: finish=unknown → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01AAA", "unknown")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
test("no assistant yet → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
expect(SessionPrompt.shouldExitLoop(user, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("assistant has no finish → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01AAA", undefined)
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
test("parentID mismatch → does NOT exit", () => {
|
||||
const user = makeUser("01AAA")
|
||||
const assistant = makeAssistant("01BBB", "01OTHER", "end_turn")
|
||||
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
|
||||
})
|
||||
|
||||
test("no user message → does NOT exit", () => {
|
||||
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
|
||||
expect(SessionPrompt.shouldExitLoop(undefined, assistant)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,14 +2,16 @@ import { describe, test, expect } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { TruncateEffect } from "../../src/tool/truncate-effect"
|
||||
import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Process } from "../../src/util/process"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import path from "path"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { writeFileStringScoped } from "../lib/filesystem"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
||||
|
||||
describe("Truncate", () => {
|
||||
describe("output", () => {
|
||||
@@ -125,11 +127,19 @@ describe("Truncate", () => {
|
||||
if (result.truncated) throw new Error("expected not truncated")
|
||||
expect("outputPath" in result).toBe(false)
|
||||
})
|
||||
|
||||
test("loads truncate effect in a fresh process", async () => {
|
||||
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
|
||||
cwd: ROOT,
|
||||
})
|
||||
|
||||
expect(out.code).toBe(0)
|
||||
}, 20000)
|
||||
})
|
||||
|
||||
describe("cleanup", () => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
|
||||
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
it.effect("deletes files older than 7 days and preserves recent files", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -142,7 +152,7 @@ describe("Truncate", () => {
|
||||
|
||||
yield* writeFileStringScoped(old, "old content")
|
||||
yield* writeFileStringScoped(recent, "recent content")
|
||||
yield* TruncateEffect.Service.use((s) => s.cleanup())
|
||||
yield* TruncateSvc.Service.use((s) => s.cleanup())
|
||||
|
||||
expect(yield* fs.exists(old)).toBe(false)
|
||||
expect(yield* fs.exists(recent)).toBe(true)
|
||||
|
||||
@@ -1009,6 +1009,392 @@ export type GlobalEvent = {
|
||||
payload: Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom keybind configurations
|
||||
*/
|
||||
export type KeybindsConfig = {
|
||||
/**
|
||||
* Leader key for keybind combinations
|
||||
*/
|
||||
leader?: string
|
||||
/**
|
||||
* Exit the application
|
||||
*/
|
||||
app_exit?: string
|
||||
/**
|
||||
* Open external editor
|
||||
*/
|
||||
editor_open?: string
|
||||
/**
|
||||
* List available themes
|
||||
*/
|
||||
theme_list?: string
|
||||
/**
|
||||
* Toggle sidebar
|
||||
*/
|
||||
sidebar_toggle?: string
|
||||
/**
|
||||
* Toggle session scrollbar
|
||||
*/
|
||||
scrollbar_toggle?: string
|
||||
/**
|
||||
* Toggle username visibility
|
||||
*/
|
||||
username_toggle?: string
|
||||
/**
|
||||
* View status
|
||||
*/
|
||||
status_view?: string
|
||||
/**
|
||||
* Export session to editor
|
||||
*/
|
||||
session_export?: string
|
||||
/**
|
||||
* Create a new session
|
||||
*/
|
||||
session_new?: string
|
||||
/**
|
||||
* List all sessions
|
||||
*/
|
||||
session_list?: string
|
||||
/**
|
||||
* Show session timeline
|
||||
*/
|
||||
session_timeline?: string
|
||||
/**
|
||||
* Fork session from message
|
||||
*/
|
||||
session_fork?: string
|
||||
/**
|
||||
* Rename session
|
||||
*/
|
||||
session_rename?: string
|
||||
/**
|
||||
* Delete session
|
||||
*/
|
||||
session_delete?: string
|
||||
/**
|
||||
* Delete stash entry
|
||||
*/
|
||||
stash_delete?: string
|
||||
/**
|
||||
* Open provider list from model dialog
|
||||
*/
|
||||
model_provider_list?: string
|
||||
/**
|
||||
* Toggle model favorite status
|
||||
*/
|
||||
model_favorite_toggle?: string
|
||||
/**
|
||||
* Share current session
|
||||
*/
|
||||
session_share?: string
|
||||
/**
|
||||
* Unshare current session
|
||||
*/
|
||||
session_unshare?: string
|
||||
/**
|
||||
* Interrupt current session
|
||||
*/
|
||||
session_interrupt?: string
|
||||
/**
|
||||
* Compact the session
|
||||
*/
|
||||
session_compact?: string
|
||||
/**
|
||||
* Scroll messages up by one page
|
||||
*/
|
||||
messages_page_up?: string
|
||||
/**
|
||||
* Scroll messages down by one page
|
||||
*/
|
||||
messages_page_down?: string
|
||||
/**
|
||||
* Scroll messages up by one line
|
||||
*/
|
||||
messages_line_up?: string
|
||||
/**
|
||||
* Scroll messages down by one line
|
||||
*/
|
||||
messages_line_down?: string
|
||||
/**
|
||||
* Scroll messages up by half page
|
||||
*/
|
||||
messages_half_page_up?: string
|
||||
/**
|
||||
* Scroll messages down by half page
|
||||
*/
|
||||
messages_half_page_down?: string
|
||||
/**
|
||||
* Navigate to first message
|
||||
*/
|
||||
messages_first?: string
|
||||
/**
|
||||
* Navigate to last message
|
||||
*/
|
||||
messages_last?: string
|
||||
/**
|
||||
* Navigate to next message
|
||||
*/
|
||||
messages_next?: string
|
||||
/**
|
||||
* Navigate to previous message
|
||||
*/
|
||||
messages_previous?: string
|
||||
/**
|
||||
* Navigate to last user message
|
||||
*/
|
||||
messages_last_user?: string
|
||||
/**
|
||||
* Copy message
|
||||
*/
|
||||
messages_copy?: string
|
||||
/**
|
||||
* Undo message
|
||||
*/
|
||||
messages_undo?: string
|
||||
/**
|
||||
* Redo message
|
||||
*/
|
||||
messages_redo?: string
|
||||
/**
|
||||
* Toggle code block concealment in messages
|
||||
*/
|
||||
messages_toggle_conceal?: string
|
||||
/**
|
||||
* Toggle tool details visibility
|
||||
*/
|
||||
tool_details?: string
|
||||
/**
|
||||
* List available models
|
||||
*/
|
||||
model_list?: string
|
||||
/**
|
||||
* Next recently used model
|
||||
*/
|
||||
model_cycle_recent?: string
|
||||
/**
|
||||
* Previous recently used model
|
||||
*/
|
||||
model_cycle_recent_reverse?: string
|
||||
/**
|
||||
* Next favorite model
|
||||
*/
|
||||
model_cycle_favorite?: string
|
||||
/**
|
||||
* Previous favorite model
|
||||
*/
|
||||
model_cycle_favorite_reverse?: string
|
||||
/**
|
||||
* List available commands
|
||||
*/
|
||||
command_list?: string
|
||||
/**
|
||||
* List agents
|
||||
*/
|
||||
agent_list?: string
|
||||
/**
|
||||
* Next agent
|
||||
*/
|
||||
agent_cycle?: string
|
||||
/**
|
||||
* Previous agent
|
||||
*/
|
||||
agent_cycle_reverse?: string
|
||||
/**
|
||||
* Toggle auto-accept mode for permissions
|
||||
*/
|
||||
permission_auto_accept_toggle?: string
|
||||
/**
|
||||
* Cycle model variants
|
||||
*/
|
||||
variant_cycle?: string
|
||||
/**
|
||||
* Clear input field
|
||||
*/
|
||||
input_clear?: string
|
||||
/**
|
||||
* Paste from clipboard
|
||||
*/
|
||||
input_paste?: string
|
||||
/**
|
||||
* Submit input
|
||||
*/
|
||||
input_submit?: string
|
||||
/**
|
||||
* Insert newline in input
|
||||
*/
|
||||
input_newline?: string
|
||||
/**
|
||||
* Move cursor left in input
|
||||
*/
|
||||
input_move_left?: string
|
||||
/**
|
||||
* Move cursor right in input
|
||||
*/
|
||||
input_move_right?: string
|
||||
/**
|
||||
* Move cursor up in input
|
||||
*/
|
||||
input_move_up?: string
|
||||
/**
|
||||
* Move cursor down in input
|
||||
*/
|
||||
input_move_down?: string
|
||||
/**
|
||||
* Select left in input
|
||||
*/
|
||||
input_select_left?: string
|
||||
/**
|
||||
* Select right in input
|
||||
*/
|
||||
input_select_right?: string
|
||||
/**
|
||||
* Select up in input
|
||||
*/
|
||||
input_select_up?: string
|
||||
/**
|
||||
* Select down in input
|
||||
*/
|
||||
input_select_down?: string
|
||||
/**
|
||||
* Move to start of line in input
|
||||
*/
|
||||
input_line_home?: string
|
||||
/**
|
||||
* Move to end of line in input
|
||||
*/
|
||||
input_line_end?: string
|
||||
/**
|
||||
* Select to start of line in input
|
||||
*/
|
||||
input_select_line_home?: string
|
||||
/**
|
||||
* Select to end of line in input
|
||||
*/
|
||||
input_select_line_end?: string
|
||||
/**
|
||||
* Move to start of visual line in input
|
||||
*/
|
||||
input_visual_line_home?: string
|
||||
/**
|
||||
* Move to end of visual line in input
|
||||
*/
|
||||
input_visual_line_end?: string
|
||||
/**
|
||||
* Select to start of visual line in input
|
||||
*/
|
||||
input_select_visual_line_home?: string
|
||||
/**
|
||||
* Select to end of visual line in input
|
||||
*/
|
||||
input_select_visual_line_end?: string
|
||||
/**
|
||||
* Move to start of buffer in input
|
||||
*/
|
||||
input_buffer_home?: string
|
||||
/**
|
||||
* Move to end of buffer in input
|
||||
*/
|
||||
input_buffer_end?: string
|
||||
/**
|
||||
* Select to start of buffer in input
|
||||
*/
|
||||
input_select_buffer_home?: string
|
||||
/**
|
||||
* Select to end of buffer in input
|
||||
*/
|
||||
input_select_buffer_end?: string
|
||||
/**
|
||||
* Delete line in input
|
||||
*/
|
||||
input_delete_line?: string
|
||||
/**
|
||||
* Delete to end of line in input
|
||||
*/
|
||||
input_delete_to_line_end?: string
|
||||
/**
|
||||
* Delete to start of line in input
|
||||
*/
|
||||
input_delete_to_line_start?: string
|
||||
/**
|
||||
* Backspace in input
|
||||
*/
|
||||
input_backspace?: string
|
||||
/**
|
||||
* Delete character in input
|
||||
*/
|
||||
input_delete?: string
|
||||
/**
|
||||
* Undo in input
|
||||
*/
|
||||
input_undo?: string
|
||||
/**
|
||||
* Redo in input
|
||||
*/
|
||||
input_redo?: string
|
||||
/**
|
||||
* Move word forward in input
|
||||
*/
|
||||
input_word_forward?: string
|
||||
/**
|
||||
* Move word backward in input
|
||||
*/
|
||||
input_word_backward?: string
|
||||
/**
|
||||
* Select word forward in input
|
||||
*/
|
||||
input_select_word_forward?: string
|
||||
/**
|
||||
* Select word backward in input
|
||||
*/
|
||||
input_select_word_backward?: string
|
||||
/**
|
||||
* Delete word forward in input
|
||||
*/
|
||||
input_delete_word_forward?: string
|
||||
/**
|
||||
* Delete word backward in input
|
||||
*/
|
||||
input_delete_word_backward?: string
|
||||
/**
|
||||
* Previous history item
|
||||
*/
|
||||
history_previous?: string
|
||||
/**
|
||||
* Next history item
|
||||
*/
|
||||
history_next?: string
|
||||
/**
|
||||
* Next child session
|
||||
*/
|
||||
session_child_cycle?: string
|
||||
/**
|
||||
* Previous child session
|
||||
*/
|
||||
session_child_cycle_reverse?: string
|
||||
/**
|
||||
* Go to parent session
|
||||
*/
|
||||
session_parent?: string
|
||||
/**
|
||||
* Suspend terminal
|
||||
*/
|
||||
terminal_suspend?: string
|
||||
/**
|
||||
* Toggle terminal title
|
||||
*/
|
||||
terminal_title_toggle?: string
|
||||
/**
|
||||
* Toggle tips on home screen
|
||||
*/
|
||||
tips_toggle?: string
|
||||
/**
|
||||
* Toggle thinking blocks visibility
|
||||
*/
|
||||
display_thinking?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Log level
|
||||
*/
|
||||
|
||||
40
packages/ui/src/components/find-assistant-messages.tsx
Normal file
40
packages/ui/src/components/find-assistant-messages.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
/**
|
||||
* Find assistant messages that are replies to a given user message.
|
||||
*
|
||||
* Scans forward from the user message index first, then falls back to scanning
|
||||
* backward. The backward scan handles clock skew where assistant messages
|
||||
* (generated server-side) sort before the user message (generated client-side
|
||||
* with an ahead clock) in the ID-sorted array.
|
||||
*/
|
||||
export function findAssistantMessages(
|
||||
messages: MessageType[],
|
||||
userIndex: number,
|
||||
userID: string,
|
||||
): AssistantMessage[] {
|
||||
if (userIndex < 0 || userIndex >= messages.length) return []
|
||||
|
||||
const result: AssistantMessage[] = []
|
||||
|
||||
// Scan forward from user message
|
||||
for (let i = userIndex + 1; i < messages.length; i++) {
|
||||
const item = messages[i]
|
||||
if (!item) continue
|
||||
if (item.role === "user") break
|
||||
if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage)
|
||||
}
|
||||
|
||||
// Scan backward to find assistant messages that sort before the user
|
||||
// message due to clock skew between client and server
|
||||
if (result.length === 0) {
|
||||
for (let i = userIndex - 1; i >= 0; i--) {
|
||||
const item = messages[i]
|
||||
if (!item) continue
|
||||
if (item.role === "user") break
|
||||
if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -827,7 +827,7 @@
|
||||
[data-slot="question-body"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px 8px 0;
|
||||
@@ -907,7 +907,7 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-strong);
|
||||
padding: 0 10px;
|
||||
padding: 16px 10px 0;
|
||||
}
|
||||
|
||||
[data-slot="question-hint"] {
|
||||
@@ -1050,8 +1050,26 @@
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-base);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="question-option"][data-custom="true"] {
|
||||
[data-slot="option-description"] {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&[data-picked="true"] {
|
||||
[data-slot="question-custom-input"]:focus-visible {
|
||||
outline: none;
|
||||
outline-offset: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="question-custom"] {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } fr
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
|
||||
import { findAssistantMessages } from "./find-assistant-messages"
|
||||
import { Card } from "./card"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
@@ -267,14 +268,7 @@ export function SessionTurn(
|
||||
const index = messageIndex()
|
||||
if (index < 0) return emptyAssistant
|
||||
|
||||
const result: AssistantMessage[] = []
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
const item = messages[i]
|
||||
if (!item) continue
|
||||
if (item.role === "user") break
|
||||
if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
|
||||
}
|
||||
return result
|
||||
return findAssistantMessages(messages, index, msg.id)
|
||||
},
|
||||
emptyAssistant,
|
||||
{ equals: same },
|
||||
|
||||
108
patches/@ai-sdk%2Fxai@2.0.51.patch
Normal file
108
patches/@ai-sdk%2Fxai@2.0.51.patch
Normal file
@@ -0,0 +1,108 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -959,7 +959,7 @@
|
||||
model: z4.string().nullish(),
|
||||
object: z4.literal("response"),
|
||||
output: z4.array(outputItemSchema),
|
||||
- usage: xaiResponsesUsageSchema,
|
||||
+ usage: xaiResponsesUsageSchema.nullish(),
|
||||
status: z4.string()
|
||||
});
|
||||
var xaiResponsesChunkSchema = z4.union([
|
||||
\ No newline at end of file
|
||||
@@ -1143,6 +1143,18 @@
|
||||
z4.object({
|
||||
type: z4.literal("response.completed"),
|
||||
response: xaiResponsesResponseSchema
|
||||
+ }),
|
||||
+ z4.object({
|
||||
+ type: z4.literal("response.function_call_arguments.delta"),
|
||||
+ item_id: z4.string(),
|
||||
+ output_index: z4.number(),
|
||||
+ delta: z4.string()
|
||||
+ }),
|
||||
+ z4.object({
|
||||
+ type: z4.literal("response.function_call_arguments.done"),
|
||||
+ item_id: z4.string(),
|
||||
+ output_index: z4.number(),
|
||||
+ arguments: z4.string()
|
||||
})
|
||||
]);
|
||||
|
||||
\ No newline at end of file
|
||||
@@ -1940,6 +1952,9 @@
|
||||
if (response2.status) {
|
||||
finishReason = mapXaiResponsesFinishReason(response2.status);
|
||||
}
|
||||
+ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
|
||||
+ finishReason = "tool-calls";
|
||||
+ }
|
||||
return;
|
||||
}
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
\ No newline at end of file
|
||||
@@ -2024,7 +2039,7 @@
|
||||
}
|
||||
}
|
||||
} else if (part.type === "function_call") {
|
||||
- if (!seenToolCalls.has(part.call_id)) {
|
||||
+ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
|
||||
seenToolCalls.add(part.call_id);
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -964,7 +964,7 @@
|
||||
model: import_v44.z.string().nullish(),
|
||||
object: import_v44.z.literal("response"),
|
||||
output: import_v44.z.array(outputItemSchema),
|
||||
- usage: xaiResponsesUsageSchema,
|
||||
+ usage: xaiResponsesUsageSchema.nullish(),
|
||||
status: import_v44.z.string()
|
||||
});
|
||||
var xaiResponsesChunkSchema = import_v44.z.union([
|
||||
\ No newline at end of file
|
||||
@@ -1148,6 +1148,18 @@
|
||||
import_v44.z.object({
|
||||
type: import_v44.z.literal("response.completed"),
|
||||
response: xaiResponsesResponseSchema
|
||||
+ }),
|
||||
+ import_v44.z.object({
|
||||
+ type: import_v44.z.literal("response.function_call_arguments.delta"),
|
||||
+ item_id: import_v44.z.string(),
|
||||
+ output_index: import_v44.z.number(),
|
||||
+ delta: import_v44.z.string()
|
||||
+ }),
|
||||
+ import_v44.z.object({
|
||||
+ type: import_v44.z.literal("response.function_call_arguments.done"),
|
||||
+ item_id: import_v44.z.string(),
|
||||
+ output_index: import_v44.z.number(),
|
||||
+ arguments: import_v44.z.string()
|
||||
})
|
||||
]);
|
||||
|
||||
\ No newline at end of file
|
||||
@@ -1935,6 +1947,9 @@
|
||||
if (response2.status) {
|
||||
finishReason = mapXaiResponsesFinishReason(response2.status);
|
||||
}
|
||||
+ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
|
||||
+ finishReason = "tool-calls";
|
||||
+ }
|
||||
return;
|
||||
}
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
\ No newline at end of file
|
||||
@@ -2019,7 +2034,7 @@
|
||||
}
|
||||
}
|
||||
} else if (part.type === "function_call") {
|
||||
- if (!seenToolCalls.has(part.call_id)) {
|
||||
+ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
|
||||
seenToolCalls.add(part.call_id);
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
\ No newline at end of file
|
||||
58
patches/solid-js@1.9.10.patch
Normal file
58
patches/solid-js@1.9.10.patch
Normal file
@@ -0,0 +1,58 @@
|
||||
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/dist/dev.cjs b/dist/dev.cjs
|
||||
index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
|
||||
--- a/dist/dev.cjs
|
||||
+++ b/dist/dev.cjs
|
||||
@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
|
||||
if (node.updatedAt != null && "observers" in node) {
|
||||
writeSignal(node, nextValue, true);
|
||||
} else if (Transition && Transition.running && node.pure) {
|
||||
+ // On first computation during transition, also set committed value #2046
|
||||
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||
Transition.sources.add(node);
|
||||
node.tValue = nextValue;
|
||||
} else node.value = nextValue;
|
||||
diff --git a/dist/dev.js b/dist/dev.js
|
||||
index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
|
||||
--- a/dist/dev.js
|
||||
+++ b/dist/dev.js
|
||||
@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
|
||||
if (node.updatedAt != null && "observers" in node) {
|
||||
writeSignal(node, nextValue, true);
|
||||
} else if (Transition && Transition.running && node.pure) {
|
||||
+ // On first computation during transition, also set committed value #2046
|
||||
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||
Transition.sources.add(node);
|
||||
node.tValue = nextValue;
|
||||
} else node.value = nextValue;
|
||||
diff --git a/dist/solid.cjs b/dist/solid.cjs
|
||||
index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
|
||||
--- a/dist/solid.cjs
|
||||
+++ b/dist/solid.cjs
|
||||
@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
|
||||
if (node.updatedAt != null && "observers" in node) {
|
||||
writeSignal(node, nextValue, true);
|
||||
} else if (Transition && Transition.running && node.pure) {
|
||||
+ // On first computation during transition, also set committed value #2046
|
||||
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||
Transition.sources.add(node);
|
||||
node.tValue = nextValue;
|
||||
} else node.value = nextValue;
|
||||
diff --git a/dist/solid.js b/dist/solid.js
|
||||
index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
|
||||
--- a/dist/solid.js
|
||||
+++ b/dist/solid.js
|
||||
@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
|
||||
if (node.updatedAt != null && "observers" in node) {
|
||||
writeSignal(node, nextValue, true);
|
||||
} else if (Transition && Transition.running && node.pure) {
|
||||
+ // On first computation during transition, also set committed value #2046
|
||||
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||
Transition.sources.add(node);
|
||||
node.tValue = nextValue;
|
||||
} else node.value = nextValue;
|
||||
Reference in New Issue
Block a user