Compare commits

..
1 Commits
Author SHA1 Message Date
Aiden Cline 494af523d1 feat(core): allow shell for explore 2026-10-04 23:31:43 -05:00
104 changed files with 1246 additions and 2362 deletions

No files matched your search

+36 -36
View File
@@ -33,7 +33,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -55,7 +55,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.23",
"version": "2.0.22",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -179,7 +179,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -205,7 +205,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -218,7 +218,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -254,7 +254,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -281,7 +281,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -298,7 +298,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -322,7 +322,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -342,7 +342,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -410,7 +410,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/gui-extensions": {
"name": "@opencode/gui-extensions",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@@ -553,7 +553,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -572,7 +572,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -585,7 +585,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -599,7 +599,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -614,7 +614,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -653,7 +653,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -683,7 +683,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -698,7 +698,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -722,7 +722,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -743,7 +743,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -765,7 +765,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -800,7 +800,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -820,7 +820,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -854,7 +854,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -873,7 +873,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -919,7 +919,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -933,7 +933,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -967,7 +967,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1002,7 +1002,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1040,7 +1040,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1081,7 +1081,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.23",
"version": "2.0.22",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -54,7 +54,6 @@ const route = Route.make({
),
},
stream: AnthropicMessages.protocol.stream,
supportsEffortUpdates: AnthropicMessages.protocol.supportsEffortUpdates,
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
+7 -15
View File
@@ -293,28 +293,20 @@ describe("Anthropic Messages effort updates", () => {
}),
)
it.effect("lowers markers on the Vertex Anthropic route for models that support them", () =>
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
Effect.gen(function* () {
const vertex = GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" })
const opus5 = yield* compileRequest(
const prepared = yield* compileRequest(
LLM.request({
model: vertex.model("claude-opus-5"),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
const opus48 = yield* compileRequest(
LLM.request({
model: vertex.model("claude-opus-4-8"),
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-5",
),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(opus5.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
expect(opus5.body.output_config).toEqual({ effort: "high" })
expect(systemMessages(opus48.body)).toHaveLength(0)
expect(opus48.body.output_config).toEqual({ effort: "low" })
expect(systemMessages(prepared.body)).toHaveLength(0)
expect(prepared.body.output_config).toEqual({ effort: "low" })
}),
)
})
+11 -171
View File
@@ -5,149 +5,11 @@ import { expectSessionTitle } from "../utils/waits"
test.use({ viewport: { width: 1440, height: 900 } })
test("keeps separate one-shot /btw answers and restores them after reload", async ({ page }, testInfo) => {
const prompts: unknown[] = []
const generations: { sessionID: string; prompt: string }[] = []
const { editor } = await openSession(page, {
name: "BtwQuestions",
onPrompt: (input) => prompts.push(input),
generate: (input) => {
generations.push(input)
return {
text: input.prompt.includes("second question")
? "Second answer"
: `First answer\n\n${"A detailed explanation of the first question.\n\n".repeat(100)}End of first answer`,
}
},
})
const panel = page.locator('[data-slot="session-btw-panel"]')
await editor.fill("/btw first question")
await editor.press("Enter")
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
await expect(panel.getByRole("textbox")).toHaveCount(0)
await expect(panel.getByRole("button", { name: "Send", exact: true })).toHaveCount(0)
await panel.getByText("End of first answer", { exact: true }).scrollIntoViewIfNeeded()
await expect(panel.getByText("End of first answer", { exact: true })).toBeInViewport()
await editor.fill("/btw second question")
await editor.press("Enter")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
const first = page.getByRole("tab", { name: "first question", exact: true })
const second = page.getByRole("tab", { name: "second question", exact: true })
await expect(first).toBeVisible()
await expect(second).toHaveAttribute("data-selected", "")
await page.screenshot({ path: testInfo.outputPath("separate-tabs.png") })
expect(generations).toHaveLength(2)
expect(generations[1]?.prompt).not.toContain("first question")
expect(generations[1]?.prompt).not.toContain("First answer")
await page.reload()
await expect(first).toBeVisible()
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await first.click()
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
await expect(panel.getByText("Second answer", { exact: true })).toHaveCount(0)
await expect(panel.getByRole("textbox")).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath("restored-answer.png") })
// Closing a tab forgets its question; the other tab and its answer survive a reload.
await first.click({ button: "middle" })
await expect(first).toHaveCount(0)
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await page.reload()
await expect(second).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
await expect(first).toHaveCount(0)
expect(generations).toHaveLength(2)
expect(prompts).toEqual([])
})
test("isolates concurrent side questions and makes an interrupted reload retryable", async ({ page }) => {
const held = Promise.withResolvers<void>()
const attempts: string[] = []
const prompts: unknown[] = []
const { editor } = await openSession(page, {
name: "BtwConcurrent",
onPrompt: (input) => prompts.push(input),
generate: async (input) => {
attempts.push(input.prompt)
if (input.prompt.includes("slow question") && attempts.length === 1) {
await held.promise
return { text: "Abandoned answer" }
}
return { text: input.prompt.includes("fast question") ? "Fast answer" : "Retried answer" }
},
})
const panel = page.locator('[data-slot="session-btw-panel"]')
await editor.fill("/btw slow question")
await editor.press("Enter")
await expect(panel.getByRole("status")).toContainText("Working")
await editor.fill("/btw fast question")
await editor.press("Enter")
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "slow question", exact: true }).click()
await expect(panel.getByRole("status")).toContainText("Working")
await expect(panel.getByText("Fast answer", { exact: true })).toHaveCount(0)
await page.reload()
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
await expect(panel.getByRole("status")).toHaveCount(0)
expect(attempts).toHaveLength(2)
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
expect(attempts).toHaveLength(3)
expect(attempts[2]).toContain("slow question")
expect(attempts[2]).not.toContain("fast question")
held.resolve()
await page.getByRole("tab", { name: "fast question", exact: true }).click()
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "slow question", exact: true }).click()
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
await expect(panel.getByText("Abandoned answer", { exact: true })).toHaveCount(0)
await expect(panel.getByRole("textbox")).toHaveCount(0)
expect(prompts).toEqual([])
})
test("keeps many long-titled side tabs usable at a narrow desktop width", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 700 })
const { editor } = await openSession(page, {
name: "BtwOverflow",
generate: () => ({ text: "Saved overflow answer" }),
})
const panel = page.locator('[data-slot="session-btw-panel"]')
const questions = Array.from({ length: 12 }, (_, index) => `Side ${index + 1}: ${"long-title-".repeat(30)}`)
for (const question of questions) {
await editor.fill(`/btw ${question}`)
await editor.press("Enter")
await expect(page.getByRole("tab", { name: question, exact: true })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
}
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
await expect(panel.getByRole("textbox")).toHaveCount(0)
await page.reload()
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("narrow-many-tabs.png") })
})
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
const generations: { sessionID: string; prompt: string }[] = []
const prompts: unknown[] = []
const generated = Promise.withResolvers<void>()
const leftBehind = Promise.withResolvers<void>()
const abandoned = Promise.withResolvers<void>()
const main = { id: "ses_btw_sidebar", title: "Side question session" }
const other = { id: "ses_btw_sidebar_other", title: "Other side question session" }
const ownerWarnings: string[] = []
@@ -166,7 +28,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." }
if (input.prompt.includes("left behind")) {
await leftBehind.promise
await abandoned.promise
return { text: "This answer arrived after the user left." }
}
@@ -185,6 +47,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await suggestion.click()
await expect(editor).toHaveText("/btw ")
await editor.press("Enter")
const panel = page.locator('[data-slot="session-btw-panel"]')
await expect(panel).toBeHidden()
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
@@ -196,10 +59,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await expect(panel).toBeVisible()
await expect(panel.getByRole("textbox")).toHaveCount(0)
await expect(panel.getByRole("status")).toContainText("Working")
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
await expect(page.getByRole("tab", { name: "/btw" })).toHaveAttribute("data-selected", "")
generated.resolve()
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
@@ -215,11 +75,13 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await editor.fill("/btw what belongs here?")
await editor.press("Enter")
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
// Leaving a session abandons its in-flight question, so it reads as failed on return.
await editor.fill("/btw is this question left behind?")
await editor.press("Enter")
await expect(panel.getByRole("status")).toContainText("Working")
@@ -227,35 +89,13 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await expectSessionTitle(page, other.title)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
// Leaving the session does not abandon its question: it is still working, and its answer lands once it arrives.
await expect(panel.getByRole("status")).toContainText("Working")
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
leftBehind.resolve()
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toHaveCount(0)
expect(generations.filter((item) => item.prompt.includes("left behind"))).toHaveLength(1)
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible()
abandoned.resolve()
await page.reload()
await expectSessionTitle(page, main.title)
await expect(page.getByRole("tab", { name: "is this question left behind?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
await expect(panel.getByRole("status")).toHaveCount(0)
await page.getByRole("tab", { name: "how does the retry loop work?", exact: true }).click()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await page.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
await expectSessionTitle(page, main.title)
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
"data-selected",
"",
)
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
await expect(panel).toHaveCount(0)
expect(ownerWarnings).toEqual([])
})
@@ -30,11 +30,9 @@ for (const position of ["top", "bottom"] as const) {
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
const more = navigation.getByRole("button", { name: "More options", exact: true })
const picker = tabs.getByRole("tab", { selected: true })
const message = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(picker).toHaveText("Session")
await expect(message).toBeVisible()
@@ -42,17 +40,9 @@ for (const position of ["top", "bottom"] as const) {
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
await expect(tabs).toHaveCSS("padding-left", "0px")
await expect(tabs).toHaveCSS("padding-right", "0px")
if (position === "top") {
const titlebar = page.locator('[data-slot="titlebar-v2"]')
await expect(titlebar).toHaveCSS("padding-top", "16px")
await expect(titlebar).toHaveCSS("height", "44px")
}
await expect
.poll(async () => {
const bounds = await navigation.boundingBox()
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
})
.toBe(true)
@@ -61,7 +51,6 @@ for (const position of ["top", "bottom"] as const) {
const bar = await tabs.boundingBox()
const input = await composer.boundingBox()
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
})
.toBe(true)
@@ -160,19 +149,16 @@ for (const position of ["top", "bottom"] as const) {
// The view resets to Session whenever the routed session changes, including through Home.
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
const openTabs = async () => {
await trigger.click()
await expect(drawer).not.toHaveAttribute("data-transitioning")
}
const openTab = async (title: string) => {
await openTabs()
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: title }).click()
await expect(drawer).toBeHidden()
await expect(trigger).toContainText(title)
}
await more.click()
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
@@ -352,7 +338,6 @@ test.describe("touch", () => {
.poll(async () => {
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
const content = await panel.boundingBox()
return !!navigation && !!content && navigation.y + navigation.height <= content.y
})
.toBe(true)
@@ -66,72 +66,6 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
}
}
test("replaces the composer when the location is not found and recovers by moving to another worktree", async ({
page,
}) => {
const directory = "/projects/deleted-worktree"
const destination = "/projects/existing-worktree"
const sessionID = "ses_location_not_found"
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
const moves: unknown[] = []
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [session],
fileList: () => [],
worktrees: [{ directory: fixture.directory }, { directory: destination, strategy: "git" }],
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
}),
})
let requests = 0
await page.route("**/api/location?**", (route) => {
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
requests++
return route.fulfill({
status: 404,
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
headers: { "access-control-allow-origin": "*" },
})
})
await page.route(`**/api/session/${sessionID}/move`, (route) => {
if (route.request().method() !== "POST") return route.fallback()
moves.push(route.request().postDataJSON())
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
const status = page.getByRole("status").filter({ hasText: "Session location unavailable" })
await expect(status).toContainText(directory)
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
// A typed not-found answer is final; retrying cannot succeed until the folder changes.
expect(requests).toBe(1)
await transport.waitForConnection()
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
await expect.poll(() => moves).toEqual([{ directory: destination }])
session.directory = destination
await transport.send({
id: "evt_location_not_found_moved",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(status).toHaveCount(0)
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
})
test("follows a live session move while the agent catalog is still loading", async ({ page }) => {
const recovery = recoveryRequests(page)
const directory = "/projects/old-tree"
@@ -650,43 +650,6 @@ test.describe("Working", () => {
})
}
test("keeps Working while a new read joins a standalone read group", async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
["one", "two", "three"].map((name) =>
toolPart(`prt_read_${name}`, "read", "completed", { path: `src/${name}.ts` }),
),
{ completed: false },
),
],
settings: { timelineDetail: timelinePresets[0].value },
})
const working = page.locator('[data-component="session-working"]')
const group = page.locator('[data-component="read-tool-group"]')
const shimmer = group.locator('[data-component="text-shimmer"]')
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_read_one,prt_read_two,prt_read_three")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "streaming", { path: "src/four.ts" })))
await expect(group).toHaveAttribute(
"data-timeline-part-ids",
"prt_read_one,prt_read_two,prt_read_three,prt_read_four",
)
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "running", { path: "src/four.ts" })))
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("one.ts, two.ts, three.ts, four.ts")
await expect(working).toBeVisible()
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "completed", { path: "src/four.ts" })))
await expect(shimmer).toHaveAttribute("data-active", "false")
await expect(working).toBeVisible()
})
for (const grouped of [false, true]) {
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation`, async ({ page }) => {
await setupTimeline(page, {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.23",
"version": "2.0.22",
"description": "",
"type": "module",
"exports": {
+2 -3
View File
@@ -145,7 +145,7 @@ function session(input: {
}
describe("Composer submission", () => {
test("applies the selection and runs a client argument command without admitting it to the session", async () => {
test("runs a client argument command without admitting it to the session", async () => {
const state = createMemoryComposerState().capture()
const image = {
@@ -169,8 +169,7 @@ describe("Composer submission", () => {
}
}).submit(new Event("submit"))
// Client commands such as /btw generate with the session's model, so the composer's selection commits first.
expect(calls).toEqual(["switch-agent", "switch-model", "btw"])
expect(calls).toEqual(["btw"])
expect(history).toEqual([])
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }, image])
expect(state.context.items()).toHaveLength(1)
+14 -31
View File
@@ -66,16 +66,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
submitting.add(input.adapter.state)
try {
// Client commands such as /btw answer with the session's model, so apply the composer's selection first,
// following the same steer rule as server commands.
const selection = currentSelection(input)
if (input.adapter.kind === "active-session" && selection && (input.delivery?.(false) ?? "steer") === "steer")
await applySelection(
input.adapter.session(),
selection,
input.adapter.controls().model.selection.trackSessionCommit,
)
clearClientCommand(input, prompt)
await clientCommand()
} catch (error) {
@@ -282,22 +272,25 @@ function readSubmission(
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
const selection = currentSelection(input)
const controls = input.adapter.controls()
const model = controls.model.selection.current()
const agent = controls.agents.current
if (!selection) {
if (!model || !agent) {
input.notify.missingSelection()
return
}
const variant = controls.model.selection.variant.current()
const retry = input.adapter.state.retry.current()
const retryID =
retry &&
retry.agent === selection.agent &&
retry.providerID === selection.model.providerID &&
retry.modelID === selection.model.modelID &&
(retry.variant ?? "default") === (selection.variant ?? "default")
retry.agent === agent &&
retry.providerID === model.provider.id &&
retry.modelID === model.id &&
(retry.variant ?? "default") === (variant ?? "default")
? retry.id
: undefined
@@ -308,25 +301,15 @@ function readSubmission(
context,
text,
images,
selection,
selection: {
agent,
model: { modelID: model.id, providerID: model.provider.id },
variant,
},
delivery: input.delivery?.(alternate) ?? "steer",
}
}
function currentSelection(input: ComposerSubmitInput): ComposerSelection | undefined {
const controls = input.adapter.controls()
const model = controls.model.selection.current()
const agent = controls.agents.current
if (!model || !agent) return
return {
agent,
model: { modelID: model.id, providerID: model.provider.id },
variant: controls.model.selection.variant.current(),
}
}
function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
submission.clear()
submission.target().mode.set("normal")
-7
View File
@@ -2,13 +2,6 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
export const dict = {
...DESKTOP_NATIVE_ENGLISH,
"session.location.unavailable": "Session location unavailable",
"session.location.description": "Choose another directory to continue this session.",
"session.location.choose": "Choose directory",
"session.location.worktree": "Choose worktree",
"session.location.worktreesFailed": "Failed to load worktrees",
"session.location.moving": "Moving session…",
"session.location.moveFailed": "Failed to move session",
"server.action.authenticate": "Authenticate",
"server.status.connecting": "Connecting over SSH…",
"server.status.authentication": "Authentication required",
@@ -1,142 +0,0 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { DockShell, DockTray } from "@opencode/ui/dock-surface"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { getFilename, sameDirectory } from "@opencode/util/path"
import { useMutation } from "@tanstack/solid-query"
import { createEffect, createResource, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { showToast } from "@/shell/notifications/toast"
import { createWorktree } from "@/workspaces/create"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
const language = useLanguage()
const server = useServer()
const data = useData()
const dialog = useDialog()
const pickDirectory = useDirectoryPicker()
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
const project = () => data.project.get(props.projectID)
const [worktrees] = createResource(
() => (state.worktreesOpen ? props.projectID : undefined),
async (projectID) => {
await server.ctx.sync.worktrees.refresh(projectID)
const items = await server.ctx.sync.worktrees.list(projectID)
if (!items) showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
return items ?? []
},
// Seed latest so even the first fetch does not enter Suspense.
{ initialValue: [] },
)
const otherWorktrees = () =>
worktrees.latest.filter((item) => item.strategy && !sameDirectory(item.directory, props.directory))
let button: HTMLButtonElement | undefined
const move = useMutation(() => ({
mutationFn: async (input: { directory?: string }) => {
// The missing worktree cannot resolve its own Location, so create from the project's saved checkout.
const current = project()
const destination =
input.directory ??
(current &&
(await createWorktree({
api: server.ctx.sdk.api,
data,
directory: current.canonical,
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
})))
if (!destination) return
await server.ctx.sdk.api.session.move({ sessionID: props.sessionID, directory: destination })
},
onError: (error) => {
setState("restoreFocus", true)
showToast({
variant: "error",
title: language.t("session.location.moveFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
},
}))
createEffect(() => {
if (!state.restoreFocus || move.isPending || dialog.active) return
setState("restoreFocus", false)
button?.focus()
})
function choose() {
if (move.isPending) return
pickDirectory({
server: server.conn,
title: language.t("session.location.choose"),
onSelect: (result) => {
const selected = Array.isArray(result) ? result[0] : result
if (selected) move.mutate({ directory: selected })
if (!selected) setState("restoreFocus", true)
},
})
}
return (
<div data-component="session-location-missing">
<DockShell class="flex flex-col gap-2 p-3">
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
<div class="min-w-0 flex flex-col gap-1">
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
<div class="text-text-base">{language.t("session.location.description")}</div>
</div>
</div>
</DockShell>
<DockTray attach="top" class="flex flex-wrap justify-end gap-2 p-2 pt-[22px]">
<Show when={project()?.vcs === "git"}>
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
{language.t("session.location.worktree")}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
<Menu.Item onSelect={() => move.mutate({})} disabled={move.isPending}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={worktrees.loading && otherWorktrees().length === 0}>
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
</Show>
<For each={otherWorktrees()}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ directory: worktree.directory })}
disabled={move.isPending}
>
<Icon name="workspace-isolated" />
<span class="truncate">{getFilename(worktree.directory)}</span>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
</Button>
</DockTray>
</div>
)
}
+12 -40
View File
@@ -1,10 +1,10 @@
import type { SessionUserActions } from "@opencode/session-ui/message"
import { getFilename, sameDirectory } from "@opencode/util/path"
import { getFilename } from "@opencode/util/path"
import { useDialog } from "@opencode/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, onMount, Show, type Accessor } from "solid-js"
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
import { Composer } from "@/composer/composer"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
@@ -32,7 +32,6 @@ import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import type { Region } from "@/runtime/extension/panels"
import { SessionLocationMissing } from "./location-missing"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -286,51 +285,24 @@ export function createActiveSessionRegion(input: {
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: {
session: SessionModel
model: SessionComposerController
suggestionBoundary: () => HTMLElement | undefined
}) {
const location = useWorkspaceLocation()
// Only the server's LocationNotFoundError for the session's own Location replaces the composer.
const missing = createMemo(() => {
const ref = location().missing
const info = props.session.data.info()
if (!ref || !info || !sameDirectory(info.location.directory, ref.directory)) return
return { directory: info.location.directory, sessionID: info.id, projectID: info.projectID }
})
return (
<SessionComposerRegion
controller={props.model.region}
composer={
<Show
when={missing()}
keyed
fallback={
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
</div>
</div>
}
>
{(current) => (
<SessionLocationMissing
sessionID={current.sessionID}
projectID={current.projectID}
directory={current.directory}
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
)}
</Show>
</div>
</div>
}
/>
)
+1 -1
View File
@@ -139,7 +139,7 @@ function ResolvedTargetSessionRoute() {
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value()}>
<LocationProvider directory={value}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
+1 -3
View File
@@ -341,9 +341,7 @@ function SessionScreenContent(props: {
</div>
<Show when={composer.active()} keyed>
{(model) => (
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
)}
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
</Show>
</>
)
@@ -374,9 +374,8 @@ function MessageTimelineView(
return message?.type === "compaction" && message.status === "running"
}
// Used and read groups keep the fallback, so each new read does not swap Working out for its short call.
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context" || row.group.type === "read")
return false
// Used groups keep the fallback regardless of disclosure state.
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context") return false
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
+3 -12
View File
@@ -59,7 +59,6 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
const location = useLocation()
const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => mobile() && settings.general.mobileTitlebarPosition() === "bottom")
const mobileTop = createMemo(() => platform.platform === "web" && mobile() && !bottom())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -151,17 +150,9 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
platform.platform === "web"
? bottom()
? "calc(28px + max(8px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))"
: mobileTop()
? "calc(28px + 16px + env(safe-area-inset-top, 0px))"
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
: undefined,
// iOS blurs page content just below the status bar in Home Screen web apps, so keep the phone titlebar row
// 16px clear of the safe area, as ChatGPT's phone header does.
"padding-top": bottom()
? "0px"
: mobileTop()
? "calc(16px + env(safe-area-inset-top, 0px))"
: "env(safe-area-inset-top, 0px)",
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
"padding-bottom": bottom() ? "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))" : "0px",
"min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow.
@@ -521,7 +512,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
classList={{
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows() && !mobileTop(),
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
"pb-[max(0px,calc(8px-var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))))]": bottom(),
"pl-4": macTrafficLights(),
// Center the 20px app icon over the sidebar's 16px icon column.
+7 -24
View File
@@ -1,40 +1,33 @@
import { createSimpleContext } from "@opencode/ui/context"
import { isLocationNotFoundError, type LocationGetOutput, type LocationRef } from "@opencode/client/promise"
import type { LocationGetOutput, LocationRef } from "@opencode/client/promise"
import { retry } from "@opencode/util/retry"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
import { useData, useServer } from "@/runtime/server/current"
export type { LocationContext } from "@/runtime/server/client"
export type WorkspaceLocation = LocationContext & {
readonly ref: LocationRef
readonly current: LocationGetOutput | undefined
/** Set only when the server reports that this exact Location's folder does not exist. */
readonly missing: LocationRef | undefined
}
const context = createSimpleContext({
name: "Location",
init: (props: { directory: string; workspaceID?: string }) => {
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
const serverSDK = useServerSDK()
const server = useServer()
const data = useData()
const ref = createMemo(
() => ({
directory: props.directory,
workspaceID: props.workspaceID,
directory: typeof props.directory === "function" ? props.directory() : props.directory,
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
}),
undefined,
{
equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID,
},
)
const current = createMemo(() => data.location.info(ref()))
const [state, setState] = createStore<{ missing?: LocationRef }>({})
createEffect(() => {
const location = ref()
@@ -42,37 +35,27 @@ const context = createSimpleContext({
onCleanup(() => {
stale = true
})
setState("missing", undefined)
if (serverSDK.connection.status() !== "connected") return
// Generic sync failures do not prove the directory is missing; only the server's typed
// LocationNotFoundError does, and retrying it cannot succeed until the folder changes.
// A failed sync does not prove the directory is missing. Keep recovery local to reads.
void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), {
retryIf: (error) => !stale && !isLocationNotFoundError(error),
}).catch((error) => {
if (stale || !isLocationNotFoundError(error)) return
setState("missing", location)
})
retryIf: () => !stale,
}).catch(() => undefined)
})
createEffect(() => {
const id = current()?.project.id
if (!id || serverSDK.connection.status() !== "connected") return
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
void server.ctx.sync.worktrees.list(id).then(() => server.ctx.sync.worktrees.refresh(id))
})
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
return createMemo<WorkspaceLocation>(() => ({
...location(),
ref: ref(),
current: current(),
missing: state.missing,
}))
},
})
export const useWorkspaceLocation: () => Accessor<WorkspaceLocation> = context.use
export const LocationProvider = context.provider
@@ -49,12 +49,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
const dialog = useDialog()
const language = useLanguage()
const policy = pickerMode(props.mode ?? "directory", props.start)
const action = {
file: language.t("dialog.directory.action.selectFile"),
directory: language.t("dialog.directory.action.selectFolder"),
}
const [root, setRoot] = createSignal("")
const [input, setInput] = createSignal("")
const [selected, setSelected] = createSignal("")
@@ -76,42 +74,31 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
() => sdk.api.location.get().catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || "")
const location = createMemo(() => {
const current = props.location ?? fallbackPath.latest
const current = props.location ?? fallbackPath()
return current ? { directory: current.directory } : undefined
})
const start = createMemo(
() =>
props.start ||
sync.data.path.home ||
props.location?.directory ||
sync.data.path.directory ||
fallbackPath.latest?.directory,
fallbackPath()?.directory,
)
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = location()?.directory
if (!base) return { query: value, items: directories.slice(0, 5) }
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: location(),
@@ -121,64 +108,48 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
})
.then((result) => result.data)
.catch(() => [])
const results = [
...directories,
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
]
return {
query: value,
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
}
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions.latest, input()))
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, eager = false) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const existing = listings.get(key)
if (existing && !eager) loads.promote(`${generation}:${key}`)
const request =
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
const current = location()
if (!current) return Promise.resolve(undefined)
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
})
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
if (!nodes) {
listings.delete(key)
if (!key) setError(true)
return false
}
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
if (!eager && advanceTreePreload(advanced, key)) {
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
}
return true
}
async function navigate(path: string) {
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
if (!value) return
const token = ++navigation
setLoading(true)
@@ -192,7 +163,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
advanced.clear()
tree?.resetPaths([])
const valid = await load("", token)
if (!activeTreeNavigation(token, navigation)) return
setRootValid(valid)
setLoading(false)
@@ -201,11 +171,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
function complete() {
const items = currentSuggestions()
const match = items[activeSuggestion()] ?? items[0]
if (!match) return
const value = displayPickerPath(match.absolute, input(), home())
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
if (match.type === "file") {
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
setSuggestionsOpen(false)
@@ -216,10 +184,8 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
if (suggestion.type === "directory") {
void navigate(suggestion.absolute)
return
}
setInput(displayPickerPath(suggestion.absolute, input(), home()))
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
setSuggestionsOpen(false)
@@ -233,31 +199,23 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
function activeSuggestionValue() {
const items = currentSuggestions()
return items[activeSuggestion()] ?? items[0]
}
const keyActions = new Map([
["ArrowDown", () => moveSuggestion(1)],
["ArrowUp", () => moveSuggestion(-1)],
[
"Enter",
() => {
const suggestion = activeSuggestionValue()
if (suggestion) chooseSuggestion(suggestion)
if (!suggestion) void navigate(input())
},
],
["Tab", complete],
])
const keyActions: Partial<Record<string, () => void>> = {
ArrowDown: () => moveSuggestion(1),
ArrowUp: () => moveSuggestion(-1),
Enter: () => {
const suggestion = activeSuggestionValue()
if (suggestion) chooseSuggestion(suggestion)
if (!suggestion) void navigate(input())
},
Tab: complete,
}
function handleInputKey(event: KeyboardEvent) {
const action = keyActions.get(event.key)
const action = keyActions[event.key]
if (!action) return
if (event.key === "Tab" && event.shiftKey) return
event.preventDefault()
action()
@@ -265,7 +223,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
function resolve() {
const path = policy.result(root(), selected(), rootValid())
if (!path) return
props.onSelect(props.multiple ? [path] : path)
dialog.close()
@@ -273,11 +230,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
onMount(() => {
const closeSuggestions = (event: PointerEvent) => {
if (event.target instanceof Node && pathArea?.contains(event.target)) return
if (pathArea?.contains(event.target as Node)) return
setSuggestionsOpen(false)
setActiveSuggestion(-1)
}
document.addEventListener("pointerdown", closeSuggestions)
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
tree = new FileTree({
@@ -310,7 +266,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
},
})
if (!container) return
tree.render({ containerWrapper: container })
tree.getFileTreeContainer()?.classList.add("directory-picker-tree")
@@ -318,7 +273,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
createEffect(() => {
const path = start()
if (!path || !location() || root()) return
void navigate(path)
})
@@ -393,16 +347,13 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
const scroller = tree
?.getFileTreeContainer()
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
if (!scroller) return
const next = nextTreeScrollTop(
scroller.scrollTop,
event.deltaY,
scroller.scrollHeight,
scroller.clientHeight,
)
if (next === scroller.scrollTop) return
event.preventDefault()
scroller.scrollTop = next
@@ -1,7 +1,7 @@
import { useDialog } from "@opencode/ui/context/dialog"
import { ServerConnection } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import { lazy, Suspense } from "solid-js"
import { lazy } from "solid-js"
import type { LocationRef } from "@opencode/client/promise"
import { directoryPickerKind } from "./policy"
@@ -24,29 +24,17 @@ export function useDirectoryPicker() {
return (input: DirectoryPickerInput) => {
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
return
}
let selected = false
const onSelect = (result: string | string[] | null) => {
selected = result !== null
input.onSelect(result)
}
const cancel = () => {
if (!selected) input.onSelect(null)
}
// Dialogs render under the caller's owner, so loading the lazy chunk would otherwise suspend the caller's boundary.
dialog.show(
() => (
<Suspense>
<DirectoryPickerDialog {...input} onSelect={onSelect} />
</Suspense>
),
cancel,
)
dialog.show(() => <DirectoryPickerDialog {...input} onSelect={onSelect} />, cancel)
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"bin": {
+10 -18
View File
@@ -20,30 +20,20 @@ import { ACPService } from "./service"
import { ACPSessions } from "./sessions"
import { ACPTurn } from "./turn"
type HandlerContext<Params> = AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
const run = Effect.runPromiseWith(yield* Effect.context<Scope.Scope>())
const catalog = yield* ACPCatalog.make(client)
// Requests can dispatch before the service below is built.
const ready = yield* Deferred.make<{
readonly service: ACPService.Interface
readonly connection: ACPConnection.Interface
}>()
const ready = yield* Deferred.make<ACPService.Interface>()
const handle =
<Params, A>(
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Failure>,
) =>
(name: string) => {
const handler = Effect.fn(name)(
function* (ctx: HandlerContext<Params>) {
const connected = yield* Deferred.await(ready)
if (ctx.requestId === undefined) return yield* call(connected.service, ctx)
return yield* call(connected.service, ctx).pipe(
Effect.provideService(ACPConnection.Responded, connected.connection.responded(ctx.requestId)),
)
},
(ctx: AgentHandlerContext<Params>) =>
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
Effect.catchTags({
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
ACPCatalogNotReadyError: (error) =>
@@ -53,7 +43,12 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
Effect.catchDefect((defect) => Effect.fail(ACPError.toRequestError(ACPError.fromUnknown(defect)))),
)
return (ctx: HandlerContext<Params>) => run(handler(ctx))
return (ctx: AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }) => {
if (ctx.requestId === undefined) return run(handler(ctx))
return run(
handler(ctx).pipe(Effect.provideService(ACPConnection.Responded, acp.connection.responded(ctx.requestId))),
)
}
}
const app = agent({ name: "opencode" })
const request = <Method extends AgentRequestMethod>(
@@ -122,10 +117,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
const sessions = yield* ACPSessions.make({ client, connection, catalog })
const capabilities = yield* Ref.make(ACPCapabilities.parse(undefined))
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
yield* Deferred.succeed(ready, {
service: ACPService.make({ client, connection, catalog, sessions, capabilities, turn }),
connection,
})
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
return acp.agent
})
+39 -13
View File
@@ -3,14 +3,15 @@ import type { Agent } from "@opencode/schema/agent"
import type { Command } from "@opencode/schema/command"
import type { Model } from "@opencode/schema/model"
import { FSUtil } from "@opencode/util/fs-util"
import { Cause, Deferred, Effect, Exit, Schedule, Semaphore, Stream, SubscriptionRef } from "effect"
import { ACPError } from "./error"
import { Cause, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
import type { ConfigOptionProvider } from "./config-option"
export const builtinCommands = new Map([
["compact", { description: "Compact the session", start: "compaction" as const }],
])
export type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ReadonlyArray<Model.Info>
readonly defaultModel: Model.Ref
readonly modes: ReadonlyArray<{ id: Agent.ID; name: string; description?: string }>
@@ -18,14 +19,24 @@ export type Catalog = {
readonly commands: ReadonlyArray<Command.Info>
}
export function findModel(models: ReadonlyArray<Model.Info>, ref: Model.Ref) {
return models.find((model) => model.providerID === ref.providerID && model.id === ref.id)
class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
reason: Schema.Literals(["models", "agents"]),
}) {
override get message() {
return this.reason === "models" ? "No models are available" : "No primary agents are available"
}
}
class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
cause: Schema.Defect(),
}) {}
export type Error = NotReadyError | LoadError
export interface Interface {
readonly get: (cwd: string) => Effect.Effect<Catalog, ACPError.CatalogError>
readonly reload: (cwd: string) => Effect.Effect<void, ACPError.CatalogError>
readonly changes: (cwd: string) => Stream.Stream<Catalog, ACPError.CatalogError>
readonly get: (cwd: string) => Effect.Effect<Catalog, Error>
readonly reload: (cwd: string) => Effect.Effect<void, Error>
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
}
type Entry = {
@@ -40,7 +51,7 @@ const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated
export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
const scope = yield* Effect.scope
const entries = new Map<string, Deferred.Deferred<Entry, ACPError.CatalogError>>()
const entries = new Map<string, Deferred.Deferred<Entry, Error>>()
const connected = yield* Deferred.make<void>()
// Requests queued behind a running load share the next one.
@@ -100,7 +111,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
const key = FSUtil.resolve(cwd)
const cached = entries.get(key)
if (cached) return Deferred.await(cached)
const loading = Deferred.makeUnsafe<Entry, ACPError.CatalogError>()
const loading = Deferred.makeUnsafe<Entry, Error>()
entries.set(key, loading)
return create(cwd).pipe(
Effect.onExit((exit) => {
@@ -144,17 +155,20 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
client.command.list({ location }),
],
{ concurrency: "unbounded" },
).pipe(Effect.mapError((cause) => new ACPError.CatalogLoadError({ cause })))
).pipe(Effect.mapError((cause) => new LoadError({ cause })))
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// The parallel default read can name a model missing from this list.
const defaultModel = preferred ? findModel(models, preferred) : models[0]
if (!defaultModel) return yield* new ACPError.CatalogNotReadyError({ reason: "models" })
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
if (!defaultModel) return yield* new NotReadyError({ reason: "models" })
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
// Core lists its resolved default agent first, the same one a new session runs.
const defaultAgent = agents[0]
if (!defaultAgent) return yield* new ACPError.CatalogNotReadyError({ reason: "agents" })
if (!defaultAgent) return yield* new NotReadyError({ reason: "agents" })
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
@@ -167,4 +181,16 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
} satisfies Catalog
})
function providers(models: ReadonlyArray<Model.Info>) {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
id: providerID,
name: providerID,
models: models
.filter((model) => model.providerID === providerID)
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
}))
}
export * as ACPCatalog from "./catalog"
+134 -114
View File
@@ -1,70 +1,51 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import type { Agent } from "@opencode/schema/agent"
import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import { Effect, Order } from "effect"
import { builtinCommands, findModel, type Catalog } from "./catalog"
import { ACPError } from "./error"
import type { Model } from "@opencode/schema/model"
import { builtinCommands, type Catalog } from "./catalog"
const DEFAULT_VARIANT_VALUE = "default"
export const DEFAULT_VARIANT_VALUE = "default"
type ConfigOptionModel = {
id: string
name: string
variants?: ReadonlyArray<string>
}
export type ConfigOptionProvider = {
id: string
name: string
models: ReadonlyArray<ConfigOptionModel>
}
type ConfigOptionMode = {
id: string
name: string
description?: string
}
export type ModelSelection = {
model: { providerID: string; modelID: string }
variant?: string
}
export type Selection = {
readonly model?: Model.Ref
readonly modeID?: Agent.ID
}
export type Change = { readonly model: Model.Ref } | { readonly modeID: Agent.ID }
export function currentModel(catalog: Catalog, selection: Selection) {
return selection.model ?? catalog.defaultModel
}
export function configOptions(catalog: Catalog, selection: Selection): SessionConfigOption[] {
export function configOptions(catalog: Catalog, selection: Selection) {
const model = currentModel(catalog, selection)
const variants = findModel(catalog.models, model)?.variants.map((variant) => variant.id) ?? []
return [
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${model.providerID}/${model.id}`,
options: catalog.models
.toSorted((a, b) => Order.String(a.providerID, b.providerID) || a.name.localeCompare(b.name))
.map((item) => ({ value: `${item.providerID}/${item.id}`, name: `${item.providerID}/${item.name}` })),
},
...(variants.length > 0
? [
{
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(model.variant, variants),
options: [...new Set([...variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" "),
})),
} satisfies SessionConfigOption,
]
: []),
{
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: selection.modeID ?? catalog.defaultModeID,
options: catalog.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
},
]
return buildConfigOptions({
providers: catalog.providers,
currentModel: { providerID: model.providerID, modelID: model.id },
currentVariant: model.variant,
modes: catalog.modes,
currentModeId: selection.modeID ?? catalog.defaultModeID,
})
}
export function availableCommands(catalog: Catalog) {
@@ -74,72 +55,111 @@ export function availableCommands(catalog: Catalog) {
]
}
export const resolveChange = Effect.fnUntraced(function* (
catalog: Catalog,
selection: Selection,
configId: string,
value: string,
) {
const current = currentModel(catalog, selection)
switch (configId) {
case "model":
return { model: yield* requireModel(catalog, value, current) }
case "effort": {
const model = findModel(catalog.models, current)
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
return yield* new ACPError.InvalidEffortError({ effort: value })
return { model: { ...current, variant: Model.VariantID.make(value) } }
}
case "mode": {
const mode = catalog.modes.find((item) => item.id === value)
if (!mode) return yield* new ACPError.InvalidModeError({ mode: value })
return { modeID: mode.id }
}
default:
return yield* new ACPError.InvalidConfigOptionError({ configId })
}
})
export function parseModelSelection(value: string, models: ReadonlyArray<Model.Info>): Model.Ref {
const providerID = models
.map((model) => model.providerID)
.toSorted()
.find((id) => value.startsWith(`${id}/`))
if (!providerID) {
const separator = value.indexOf("/")
if (separator === -1) return { providerID: Provider.ID.make(value), id: Model.ID.make("") }
return { providerID: Provider.ID.make(value.slice(0, separator)), id: Model.ID.make(value.slice(separator + 1)) }
}
const id = Model.ID.make(value.slice(providerID.length + 1))
if (findModel(models, { providerID, id })) return { providerID, id }
const separator = id.lastIndexOf("/")
const baseID = Model.ID.make(separator === -1 ? id : id.slice(0, separator))
const variant = separator === -1 ? undefined : id.slice(separator + 1)
const model = findModel(models, { providerID, id: baseID })
if (model && variant && model.variants.some((item) => item.id === variant))
return { providerID, id: baseID, variant: Model.VariantID.make(variant) }
return { providerID, id }
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants =
input.providers
.find((provider) => provider.id === input.currentModel.providerID)
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
const effort =
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
return [
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, value: string, current: Model.Ref) {
const selected = parseModelSelection(value, catalog.models)
const model = findModel(catalog.models, selected)
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.providerID, modelId: value })
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selectedVariant?.id ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
})
export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
options: input.providers.flatMap((provider) =>
provider.models
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
),
}
}
export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption {
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
}
}
export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (!provider) {
const separator = modelId.indexOf("/")
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
}
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
const separator = modelID.lastIndexOf("/")
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
const model = provider.models.find((item) => item.id === baseModelID)
if (model && variant && model.variants?.includes(variant)) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
return { model: { providerID: provider.id, modelID } }
}
export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
if (variants.includes(variant)) return variant
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
}
+24 -3
View File
@@ -6,7 +6,7 @@ import type {
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/effect"
import { Form } from "@opencode/schema/form"
import { Effect, Option, Schema } from "effect"
import { Cause, Effect, Option, Schema } from "effect"
import type { Capabilities } from "./capabilities"
import { ACPChild } from "./child"
import { ACPClient } from "./client"
@@ -30,8 +30,28 @@ type Input = {
readonly clientSessionID: string
readonly child?: ACPChild.Session
readonly toolCallSent: boolean
readonly settled: Effect.Effect<void>
}
type Outcome = Form.Answer | "cancel" | "settled"
export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
yield* Effect.uninterruptibleMask((restore) =>
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
restore(
cancelled.pipe(
Effect.as("cancel" as const),
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
Effect.raceFirst(ask(input)),
),
).pipe(
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP elicitation failed", cause)),
Effect.catchCause(() => Effect.succeed("cancel" as const)),
Effect.flatMap((outcome) => respond(input, outcome)),
),
)
})
export const UnshownQuestionMessage =
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
@@ -83,7 +103,7 @@ function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answ
)
}
export const ask = Effect.fnUntraced(function* (input: Input) {
const ask = Effect.fnUntraced(function* (input: Input) {
const source = input.toolCallSent ? Schema.decodeUnknownOption(ToolSource)(input.form.metadata) : Option.none()
const toolCallID = Option.getOrUndefined(Option.map(source, (metadata) => metadata.tool.id))
const response = yield* input.connection.createElicitation({
@@ -96,7 +116,8 @@ export const ask = Effect.fnUntraced(function* (input: Input) {
return answer(input.form, response) ?? "cancel"
})
export function respond(input: Input, outcome: Form.Answer | "cancel") {
function respond(input: Input, outcome: Outcome) {
if (outcome === "settled") return Effect.void
if (outcome === "cancel") return cancel(input.client, input.form)
return input.client.session.form
.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome })
+2 -15
View File
@@ -1,5 +1,6 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
import type { ACPCatalog } from "./catalog"
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
sessionId: Schema.String,
@@ -55,20 +56,6 @@ export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailable
{},
) {}
export class CatalogNotReadyError extends Schema.TaggedError<CatalogNotReadyError>()("ACPCatalogNotReadyError", {
reason: Schema.Literals(["models", "agents"]),
}) {
override get message() {
return this.reason === "models" ? "No models are available" : "No primary agents are available"
}
}
export class CatalogLoadError extends Schema.TaggedError<CatalogLoadError>()("ACPCatalogLoadError", {
cause: Schema.Defect(),
}) {}
export type CatalogError = CatalogNotReadyError | CatalogLoadError
export type Error =
| SessionNotFoundError
| SessionDirectoryMismatchError
@@ -83,7 +70,7 @@ export type Error =
| ServiceFailureError
| ServerUnavailableError
export type Failure = Error | RequestError | CatalogError
export type Failure = Error | RequestError | ACPCatalog.Error
export function toRequestError(error: Error): RequestError {
switch (error._tag) {
+27 -15
View File
@@ -5,20 +5,11 @@ import type { Permission } from "@opencode/schema/permission"
import type { Session } from "@opencode/schema/session"
import { Patch } from "@opencode/util/patch"
import { applyPatch } from "diff"
import { Effect, Option, Schema } from "effect"
import { Cause, Effect, Option, Schema } from "effect"
import { ACPChild } from "./child"
import { ACPClient } from "./client"
import type { ACPConnection } from "./connection"
import {
absolutePath,
canonicalName,
filePath,
patchHunks,
pendingToolCall,
stringValue,
toLocations,
type ToolInput,
} from "./tool"
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
@@ -33,6 +24,7 @@ type Input = {
readonly cwd: string
readonly tool?: Tool
readonly child?: ACPChild.Session
readonly settled: Effect.Effect<void>
}
const options: PermissionOption[] = [
@@ -43,7 +35,24 @@ const options: PermissionOption[] = [
const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
export const ask = Effect.fnUntraced(function* (input: Input) {
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
yield* Effect.uninterruptibleMask((restore) =>
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
restore(
cancelled.pipe(
Effect.as("reject" as const),
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
Effect.raceFirst(ask(input)),
),
).pipe(
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
Effect.catchCause(() => Effect.succeed("reject" as const)),
Effect.flatMap((decision) => respond(input, decision)),
),
)
})
const ask = Effect.fnUntraced(function* (input: Input) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
@@ -71,7 +80,8 @@ export const ask = Effect.fnUntraced(function* (input: Input) {
return selected === "once" || selected === "always" ? selected : "reject"
})
export function respond(input: Input, decision: Permission.Reply) {
function respond(input: Input, decision: Permission.Reply | "settled") {
if (decision === "settled") return Effect.void
return input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }).pipe(
Effect.catchTag("PermissionNotFoundError", () => Effect.void),
Effect.catch(ACPClient.classify),
@@ -85,7 +95,8 @@ const permissionPreviews = Effect.fnUntraced(function* (
metadata: ToolInput | undefined,
cwd: string,
) {
if (canonicalName(toolName) === "patch") return yield* patchPreviews(input, cwd)
const tool = toolName.toLocaleLowerCase()
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
const previews = yield* Effect.forEach(
files,
@@ -126,7 +137,7 @@ function diff(path: string, oldText: string | null, newText: string) {
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
if (previews.length > 1) return `${previews.length} files`
switch (canonicalName(toolName)) {
switch (toolName.toLocaleLowerCase()) {
case "external_directory":
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
case "webfetch":
@@ -140,6 +151,7 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
case "edit":
case "write":
case "patch":
case "apply_patch":
return filePath(input) ?? previews[0]?.path
default:
return undefined
-61
View File
@@ -1,61 +0,0 @@
import type { ContentBlock } from "@agentclientprotocol/sdk"
import type { Command } from "@opencode/schema/command"
import { SessionMessage } from "@opencode/schema/session-message"
import { Effect } from "effect"
import { access, constants } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { builtinCommands, type Catalog } from "./catalog"
import { linkReference, promptContentToParts, type PromptPart } from "./content"
import { ACPError } from "./error"
import type { ACPTranslate } from "./translate"
export type Prepared = {
readonly start: ACPTranslate.TurnStart
readonly text: string
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: Command.Info
}
export const prepare = Effect.fnUntraced(function* (catalog: Catalog, content: readonly ContentBlock[]) {
if (content.some((block) => block.type === "image" && !block.data && !block.uri)) {
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
}
const parts = yield* Effect.forEach(promptContentToParts(content), referenceUnreadableFile, {
concurrency: "unbounded",
})
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const slash = detectSlashCommand(text)
const id = SessionMessage.ID.create()
return {
start:
slash && builtinCommands.get(slash.name)?.start === "compaction"
? { type: "compaction", id }
: { type: "input", id },
text,
files: visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : [])),
synthetic: parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : [])),
slash,
command: slash ? catalog.commands.find((item) => item.name === slash.name) : undefined,
} satisfies Prepared
})
function referenceUnreadableFile(part: PromptPart) {
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
Effect.as(part),
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
)
}
function detectSlashCommand(text: string) {
const value = text.trim()
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
export * as ACPPrompt from "./prompt"
+62 -15
View File
@@ -1,5 +1,6 @@
import type { OpenCodeClient } from "@opencode/client/effect"
import { SessionsCursor } from "@opencode/protocol/groups/session"
import { Model } from "@opencode/schema/model"
import { AbsolutePath } from "@opencode/schema/schema"
import { FSUtil } from "@opencode/util/fs-util"
import { DateTime, Effect, Ref, Schema } from "effect"
@@ -35,9 +36,9 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { ACPCapabilities, type Capabilities } from "./capabilities"
import type { ACPCatalog } from "./catalog"
import type { ACPCatalog, Catalog } from "./catalog"
import { ACPClient } from "./client"
import { configOptions, resolveChange } from "./config-option"
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
import type { ACPConnection } from "./connection"
import { ACPDirectories } from "./directories"
import { ACPError } from "./error"
@@ -77,21 +78,42 @@ export function make(input: {
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
})
const withReload = <A>(attached: Attached, attempt: Effect.Effect<A, ACPError.Failure>) => {
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(attempt))
return attempt.pipe(
const withReload = <A>(attached: Attached, select: Effect.Effect<A, ACPError.Failure>) => {
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(select))
return select.pipe(
Effect.catchTags({ ACPInvalidModelError: retry, ACPInvalidModeError: retry, ACPInvalidEffortError: retry }),
)
}
const select = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
const change = yield* resolveChange(
yield* input.catalog.get(attached.cwd),
yield* Ref.get(attached.selection),
configId,
value,
)
yield* input.sessions.select(attached, change)
const selectOption = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
const catalog = yield* input.catalog.get(attached.cwd)
const current = currentModel(catalog, yield* Ref.get(attached.selection))
switch (configId) {
case "model":
return yield* selectModel(attached, yield* requireModel(catalog, value, current))
case "effort":
return yield* selectModel(attached, yield* requireEffort(catalog, value, current))
case "mode":
return yield* selectMode(attached, value)
default:
return yield* new ACPError.InvalidConfigOptionError({ configId })
}
})
// Update selection before switching so the echoed event is a no-op.
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: Model.Ref) {
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
yield* input.client.session.switchModel({ sessionID: attached.id, model }).pipe(Effect.catch(ACPClient.classify))
})
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
const catalog = yield* input.catalog.get(attached.cwd)
const mode = catalog.modes.find((item) => item.id === modeID)
if (!mode) return yield* new ACPError.InvalidModeError({ mode: modeID })
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID: mode.id }))
yield* input.client.session
.switchAgent({ sessionID: attached.id, agent: mode.id })
.pipe(Effect.catch(ACPClient.classify))
})
const getSession = Effect.fnUntraced(function* (sessionId: string, cwd: string) {
@@ -223,12 +245,12 @@ export function make(input: {
const attached = yield* input.sessions.require(params.sessionId)
const value = params.value
if (typeof value !== "string") return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
yield* withReload(attached, select(attached, params.configId, value))
yield* withReload(attached, selectOption(attached, params.configId, value))
return { configOptions: yield* currentOptions(attached) }
}),
setSessionMode: Effect.fnUntraced(function* (params) {
const attached = yield* input.sessions.require(params.sessionId)
yield* withReload(attached, select(attached, "mode", params.modeId))
yield* withReload(attached, selectMode(attached, params.modeId))
return {}
}),
prompt: input.turn.prompt,
@@ -248,4 +270,29 @@ const supportedMcpServers = Effect.fnUntraced(function* (servers: readonly McpSe
return supported
})
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: Model.Ref) {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
)
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selectedVariant?.id ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
})
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: Model.Ref) {
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
if (!model || (effort !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === effort)))
return yield* new ACPError.InvalidEffortError({ effort })
return { ...current, variant: Model.VariantID.make(effort) } satisfies Model.Ref
})
export * as ACPService from "./service"
+3 -13
View File
@@ -6,7 +6,7 @@ import type { Session } from "@opencode/schema/session"
import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
import type { ACPCatalog, Catalog } from "./catalog"
import { ACPClient } from "./client"
import { availableCommands, configOptions, type Change, type Selection } from "./config-option"
import { availableCommands, configOptions, type Selection } from "./config-option"
import { ACPConnection } from "./connection"
import { ACPError } from "./error"
@@ -28,13 +28,12 @@ export interface Interface {
readonly release: (attached: Attached) => Effect.Effect<void>
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
readonly select: (attached: Attached, change: Change) => Effect.Effect<void, ACPError.Error>
}
type Entry = {
readonly attached: Attached
readonly scope: Scope.Closeable
readonly selected: Queue.Queue<Change>
readonly selected: Queue.Queue<Selection>
}
type SelectedEvent = Extract<OpenCodeEvent, { type: "session.model.selected" | "session.agent.selected" }>
@@ -131,7 +130,7 @@ export const make = Effect.fnUntraced(function* (input: {
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
},
scope: Scope.forkUnsafe(scope),
selected: yield* Queue.unbounded<Change>(),
selected: yield* Queue.unbounded<Selection>(),
}
// Swap synchronously so concurrent attaches of one ID cannot both keep a scope.
const replaced = sessions.get(session.id)
@@ -180,15 +179,6 @@ export const make = Effect.fnUntraced(function* (input: {
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
}),
// Update selection before switching so the echoed event is a no-op.
select: Effect.fnUntraced(function* (attached, change) {
yield* Ref.update(attached.selection, (selection) => ({ ...selection, ...change }))
yield* (
"model" in change
? input.client.session.switchModel({ sessionID: attached.id, model: change.model })
: input.client.session.switchAgent({ sessionID: attached.id, agent: change.modeID })
).pipe(Effect.catch(ACPClient.classify))
}),
} satisfies Interface
})
+10 -15
View File
@@ -8,12 +8,14 @@ import { Result } from "effect"
export type ToolInput = Record<string, unknown>
function toToolKind(toolName: string): ToolKind {
switch (canonicalName(toolName)) {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "apply_patch":
case "patch":
case "write":
return "edit"
@@ -25,6 +27,7 @@ function toToolKind(toolName: string): ToolKind {
return "search"
case "read":
return "read"
case "task":
case "subagent":
return "think"
default:
@@ -33,7 +36,8 @@ function toToolKind(toolName: string): ToolKind {
}
export function toLocations(toolName: string, input: ToolInput, cwd: string): ToolCallLocation[] {
switch (canonicalName(toolName)) {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return locationFrom(cwd, stringValue(input.workdir) ?? stringValue(input.cwd) ?? cwd)
case "read":
@@ -41,6 +45,7 @@ export function toLocations(toolName: string, input: ToolInput, cwd: string): To
case "write":
return locationFrom(cwd, filePath(input))
case "patch":
case "apply_patch":
return locationFrom(
cwd,
...patchHunks(input).flatMap((hunk) => [hunk.path, hunk.type === "update" ? hunk.movePath : undefined]),
@@ -100,7 +105,7 @@ export function completedToolUpdate(input: {
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const firstText = input.content.find((part) => part.type === "text")
const read = canonicalName(input.toolName) === "read" && firstText ? readDisplayText(firstText.text) : undefined
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
@@ -168,7 +173,8 @@ function rawInput(toolName: string, input: ToolInput, cwd: string): ToolInput {
}
function isShell(toolName: string) {
return canonicalName(toolName) === "shell"
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
@@ -178,17 +184,6 @@ function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
)
}
const V1Aliases = new Map([
["bash", "shell"],
["task", "subagent"],
["apply_patch", "patch"],
])
export function canonicalName(toolName: string) {
const name = toolName.toLocaleLowerCase()
return V1Aliases.get(name) ?? name
}
// Sessions migrated from V1 keep their original `filePath` tool inputs.
export function filePath(input: ToolInput) {
return stringValue(input.path) ?? stringValue(input.filePath)
+143 -150
View File
@@ -15,12 +15,14 @@ export type TurnStart = { readonly type: "input" | "compaction"; readonly id: Se
export type Terminal = "succeeded" | "failed" | "interrupted"
export type TurnContext = {
export type Context = {
readonly sessionID: Session.ID
readonly cwd: string
readonly start: TurnStart
readonly childUpdates: boolean
readonly compaction: boolean
/** Background mode follows open children after the turn ends and never writes `session/update`. */
readonly mode: "turn" | "background"
}
type Tool = {
@@ -53,7 +55,6 @@ export type TurnState = {
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
type FormEvent = Extract<OpenCodeEvent, { type: "form.created" }>
type CreatedEvent = Extract<OpenCodeEvent, { type: "session.created" }>
export type Output =
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
@@ -64,10 +65,15 @@ export type Output =
readonly tool?: Tool
readonly child?: ACPChild.Session
}
| { readonly _tag: "FormAsk"; readonly form: FormEvent["data"]["form"]; readonly child?: ACPChild.Session }
| {
readonly _tag: "FormAsk"
readonly form: FormEvent["data"]["form"]
readonly child?: ACPChild.Session
readonly toolCallSent: boolean
}
| { readonly _tag: "AskSettled"; readonly id: string }
export type Folded = {
type Step = {
readonly state: TurnState
readonly outputs: ReadonlyArray<Output>
readonly terminal?: Terminal
@@ -83,11 +89,27 @@ export const initial: TurnState = {
asks: new Set(),
}
export function fold(state: TurnState, event: OpenCodeEvent, ctx: TurnContext): Folded {
if (event.type === "session.created") return childCreated(state, event, ctx)
export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step {
if (event.type === "session.created") {
const parentID = event.data.parentID
if (!parentID) return { state, outputs: [] }
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
if (!parent && (ctx.mode === "background" || parentID !== ctx.sessionID)) return { state, outputs: [] }
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
return {
state: {
...state,
children: new Map(state.children).set(child.id, child),
openChildren: new Set(state.openChildren).add(child.id),
},
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
}
}
const sessionID = sessionIDFromEvent(event)
const child = sessionID ? state.children.get(sessionID) : undefined
const eventSessionID = sessionIDFromEvent(event)
const child = eventSessionID ? state.children.get(eventSessionID) : undefined
if (ctx.mode === "background" && !child) return { state, outputs: [] }
const send = (update: SessionUpdate) => route(ctx, child, update)
if (event.type === "permission.asked" && (event.data.sessionID === ctx.sessionID || child)) {
const tool = event.data.source?.id
@@ -101,7 +123,14 @@ export function fold(state: TurnState, event: OpenCodeEvent, ctx: TurnContext):
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
return {
state: { ...state, asks: new Set(state.asks).add(event.data.form.id) },
outputs: [{ _tag: "FormAsk", form: event.data.form, child }],
outputs: [
{
_tag: "FormAsk",
form: event.data.form,
child,
toolCallSent: ctx.mode === "turn" && (!child || !ctx.childUpdates),
},
],
}
}
const settledID =
@@ -115,148 +144,22 @@ export function fold(state: TurnState, event: OpenCodeEvent, ctx: TurnContext):
asks.delete(settledID)
return { state: { ...state, asks }, outputs: [{ _tag: "AskSettled", id: settledID }] }
}
if (!sessionID || (sessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
if (event.type === "session.inbox.delivered" && event.data.inboxID === ctx.start.id)
return { state: { ...state, started: true }, outputs: [] }
if (!state.started) return { state, outputs: [] }
return child ? childEvent(state, event, ctx, child) : rootEvent(state, event, ctx)
}
export function fromTrackedChild(state: TurnState, event: OpenCodeEvent) {
const sessionID = event.type === "session.created" ? event.data.parentID : sessionIDFromEvent(event)
return sessionID !== undefined && state.children.has(sessionID)
}
export function failure(state: TurnState) {
const error = state.stepError ?? state.executionError
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
return new ACPError.ServiceFailureError({
safeMessage: error.message || "OpenCode prompt failed",
service: "session",
errorName: error.type,
})
}
return undefined
}
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
const tokens = state.usage?.turn
const usage = tokens
? {
inputTokens: tokens.input,
outputTokens: tokens.output,
totalTokens: TokenUsage.total(tokens),
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
}
: undefined
const error = (state.stepError ?? state.executionError)?.type
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
// Interruption clears the projected retry, so a retry pending at interrupt is reported here.
const retry = state.retries.get(sessionID)
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
}
// Child compactions are left to the background consumer.
export function abandon(state: TurnState, ctx: TurnContext): Folded {
const compaction = state.compactions.get(ctx.sessionID)
return {
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
outputs: [
...[...state.tools.values()].flatMap((tool) =>
route(ctx, state.children.get(tool.sessionID), {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: tool.id,
toolName: tool.name,
input: tool.input,
metadata: tool.metadata,
content: [],
error: "Cancelled",
cwd: ctx.cwd,
}),
}),
),
...(compaction
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
: []),
],
}
}
export function reasoningMessageID(messageID: string, ordinal: number) {
return `${messageID}:reasoning:${ordinal}`
}
function childCreated(state: TurnState, event: CreatedEvent, ctx: TurnContext): Folded {
const parentID = event.data.parentID
if (!parentID) return { state, outputs: [] }
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
if (!parent && parentID !== ctx.sessionID) return { state, outputs: [] }
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
return {
state: {
...state,
children: new Map(state.children).set(child.id, child),
openChildren: new Set(state.openChildren).add(child.id),
},
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
}
}
function rootEvent(state: TurnState, event: OpenCodeEvent, ctx: TurnContext): Folded {
switch (event.type) {
case "session.step.started":
return sessionEvent({ ...state, stepError: undefined }, event, ctx, undefined)
case "session.step.ended":
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
case "session.step.failed": {
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
}
case "session.execution.succeeded":
return { state, outputs: [], terminal: "succeeded" }
case "session.execution.interrupted":
return { state, outputs: [], terminal: "interrupted" }
case "session.execution.failed":
return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
default:
return sessionEvent(state, event, ctx, undefined)
}
}
function childEvent(state: TurnState, event: OpenCodeEvent, ctx: TurnContext, child: ACPChild.Session): Folded {
switch (event.type) {
case "session.execution.started":
return { state, outputs: childStatus(ctx, child, { type: "status", status: "running" }) }
case "session.execution.succeeded":
return childEnded(state, ctx, child, { type: "status", status: "completed" })
case "session.execution.interrupted":
return childEnded(state, ctx, child, { type: "status", status: "interrupted" })
case "session.execution.failed":
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error })
default:
return sessionEvent(state, event, ctx, child)
}
}
function sessionEvent(
state: TurnState,
event: OpenCodeEvent,
ctx: TurnContext,
child: ACPChild.Session | undefined,
): Folded {
const sessionID = child?.id ?? ctx.sessionID
const send = (update: SessionUpdate) => route(ctx, child, update)
switch (event.type) {
case "session.step.started":
if (!state.retries.has(sessionID)) return { state, outputs: [] }
return { state, outputs: child ? childStatus(ctx, child, { type: "status", status: "running" }) : [] }
case "session.step.started": {
const next = child ? state : { ...state, stepError: undefined }
if (!state.retries.has(eventSessionID)) return { state: next, outputs: [] }
return {
state: { ...state, retries: without(state.retries, sessionID) },
state: { ...next, retries: without(state.retries, eventSessionID) },
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } }),
}
}
case "session.retry.scheduled": {
const retry = {
attempt: event.data.attempt,
@@ -264,7 +167,7 @@ function sessionEvent(
error: event.data.error,
}
return {
state: { ...state, retries: new Map(state.retries).set(sessionID, retry) },
state: { ...state, retries: new Map(state.retries).set(eventSessionID, retry) },
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } }),
}
}
@@ -280,7 +183,7 @@ function sessionEvent(
}
case "session.compaction.delta": {
const update = ACPCompaction.chunk(
state.compactions.get(sessionID),
state.compactions.get(eventSessionID),
event.data.text,
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
)
@@ -396,31 +299,121 @@ function sessionEvent(
}),
}
}
case "session.step.ended":
if (child) return { state, outputs: [] }
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
case "session.step.failed": {
if (child) return { state, outputs: [] }
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
}
case "session.execution.succeeded":
if (!child) return { state, outputs: [], terminal: "succeeded" }
return childEnded(state, ctx, child, { type: "status", status: "completed" }, "succeeded")
case "session.execution.interrupted":
if (!child) return { state, outputs: [], terminal: "interrupted" }
return childEnded(state, ctx, child, { type: "status", status: "interrupted" }, "interrupted")
case "session.execution.failed":
if (!child) return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error }, "failed")
default:
return { state, outputs: [] }
}
}
export function failure(state: TurnState) {
const error = state.stepError ?? state.executionError
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
return new ACPError.ServiceFailureError({
safeMessage: error.message || "OpenCode prompt failed",
service: "session",
errorName: error.type,
})
}
return undefined
}
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
const tokens = state.usage?.turn
const usage = tokens
? {
inputTokens: tokens.input,
outputTokens: tokens.output,
totalTokens: TokenUsage.total(tokens),
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
}
: undefined
const error = (state.stepError ?? state.executionError)?.type
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
// Interruption clears the projected retry, so a retry pending at interrupt is reported here.
const retry = state.retries.get(sessionID)
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
}
// Child compactions are left to the background consumer.
export function abandon(state: TurnState, ctx: Context): Step {
const compaction = state.compactions.get(ctx.sessionID)
return {
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
outputs: [
...[...state.tools.values()].flatMap((tool) =>
route(ctx, state.children.get(tool.sessionID), {
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: tool.id,
toolName: tool.name,
input: tool.input,
metadata: tool.metadata,
content: [],
error: "Cancelled",
cwd: ctx.cwd,
}),
}),
),
...(compaction
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
: []),
],
}
}
export function reasoningMessageID(messageID: string, ordinal: number) {
return `${messageID}:reasoning:${ordinal}`
}
function newTool(sessionID: string, id: string, name = "tool"): Tool {
return { sessionID, id, name, input: {}, metadata: {} }
}
function route(ctx: TurnContext, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
if (!child) return [{ _tag: "SessionUpdate", update }]
function route(ctx: Context, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
const projected = ACPChild.project(update, child)
if (ctx.childUpdates) return childStatus(ctx, child, { type: "update", update: projected })
return [{ _tag: "SessionUpdate", update: projected }]
return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update: projected }] : []
}
function childStatus(ctx: TurnContext, child: ACPChild.Session, event: ACPChild.Event): Output[] {
function childStatus(ctx: Context, child: ACPChild.Session, event: ACPChild.Event): Output[] {
if (!ctx.childUpdates) return []
return [{ _tag: "ChildUpdate", update: ACPChild.update(ctx.sessionID, child, event) }]
}
function childEnded(state: TurnState, ctx: TurnContext, child: ACPChild.Session, status: ACPChild.Event): Folded {
function childEnded(
state: TurnState,
ctx: Context,
child: ACPChild.Session,
status: ACPChild.Event,
terminal: Terminal,
): Step {
const openChildren = new Set(state.openChildren)
openChildren.delete(child.id)
return { state: { ...state, openChildren }, outputs: childStatus(ctx, child, status) }
return {
state: { ...state, openChildren },
outputs: childStatus(ctx, child, status),
...(ctx.mode === "background" && openChildren.size === 0 ? { terminal } : {}),
}
}
function recordStep(state: TurnState, tokens: TokenUsage.Info): TurnState {
+189 -149
View File
@@ -1,5 +1,7 @@
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
import type { Command } from "@opencode/schema/command"
import { SessionMessage } from "@opencode/schema/session-message"
import { TokenUsage } from "@opencode/schema/token-usage"
import {
Cause,
@@ -16,16 +18,18 @@ import {
Scope,
Stream,
} from "effect"
import { access, constants } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import type { Capabilities } from "./capabilities"
import type { ACPCatalog } from "./catalog"
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
import { ACPChild } from "./child"
import { ACPClient } from "./client"
import { currentModel } from "./config-option"
import type { ACPConnection } from "./connection"
import { linkReference, promptContentToParts, type PromptPart } from "./content"
import { ACPElicitation } from "./elicitation"
import { ACPError } from "./error"
import { ACPPermission } from "./permission"
import { ACPPrompt } from "./prompt"
import type { ACPSessions, Attached } from "./sessions"
import { ACPTranslate } from "./translate"
@@ -41,19 +45,24 @@ export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/c
defaultValue: () => "5 seconds",
})
type PreparedPrompt = {
readonly start: ACPTranslate.TurnStart
readonly text: string
readonly files: Array<{ readonly uri: string; readonly name?: string }>
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: Command.Info
}
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
type Subscription = {
readonly scope: Scope.Closeable
readonly events: Queue.Dequeue<OpenCodeEvent, unknown>
readonly askQueue: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
readonly settled: Map<string, Deferred.Deferred<void>>
}
type Turn = {
readonly ctx: ACPTranslate.TurnContext
readonly state: Ref.Ref<ACPTranslate.TurnState>
readonly subscription: Subscription
/** Asks run serially off the event stream. */
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
readonly cancelled: Deferred.Deferred<void>
readonly background: boolean
readonly settled: Map<string, Deferred.Deferred<void>>
}
export const make = Effect.fnUntraced(function* (input: {
@@ -67,7 +76,7 @@ export const make = Effect.fnUntraced(function* (input: {
const drainTimeout = yield* CancelDrainTimeout
const turns = yield* FiberMap.make<string, PromptResponse, ACPError.Failure>()
const open = Effect.fnUntraced(function* (ctx: ACPTranslate.TurnContext, state: Ref.Ref<ACPTranslate.TurnState>) {
const subscribe = Effect.fnUntraced(function* () {
// Parented to the service scope; the session scope may already be closed.
const subscriptionScope = yield* Scope.fork(scope)
const subscription: Subscription = {
@@ -75,18 +84,19 @@ export const make = Effect.fnUntraced(function* (input: {
events: yield* input.client.event
.subscribe()
.pipe(Stream.toQueue({ capacity: "unbounded" }), Scope.provide(subscriptionScope)),
askQueue: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
cancelled: yield* Deferred.make<void>(),
settled: new Map(),
}
yield* Queue.take(subscription.askQueue).pipe(
yield* Queue.take(subscription.asks).pipe(
Effect.flatten,
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP ask reply failed", cause),
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP permission reply failed", cause),
),
Effect.forever,
Effect.forkIn(subscriptionScope),
)
return { ctx, state, subscription, cancelled: yield* Deferred.make<void>(), background: false } satisfies Turn
return subscription
})
const take = (subscription: Subscription) =>
@@ -98,44 +108,35 @@ export const make = Effect.fnUntraced(function* (input: {
const asksSettled = Effect.fnUntraced(function* (subscription: Subscription) {
const settled = yield* Deferred.make<void>()
yield* Queue.offer(subscription.askQueue, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
yield* Queue.offer(subscription.asks, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
yield* Deferred.await(settled)
})
const queueAsk = <A>(
turn: Turn,
id: string,
ask: Effect.Effect<A, unknown>,
fallback: A,
respond: (outcome: A) => Effect.Effect<void, ACPError.Error | RequestError>,
const reply = (
subscription: Subscription,
ctx: ACPTranslate.Context,
ask: PermissionAsk,
settled: Deferred.Deferred<void>,
) =>
Effect.gen(function* () {
const settled = yield* Deferred.make<void>()
turn.subscription.settled.set(id, settled)
yield* Queue.offer(
turn.subscription.askQueue,
Effect.uninterruptibleMask((restore) =>
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
restore(
Deferred.await(turn.cancelled).pipe(
Effect.as(fallback),
Effect.raceFirst(Deferred.await(settled).pipe(Effect.as("settled" as const))),
Effect.raceFirst(ask),
),
).pipe(
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP ask failed", cause)),
Effect.catchCause(() => Effect.succeed(fallback)),
Effect.flatMap((outcome) => (outcome === "settled" ? Effect.void : respond(outcome))),
),
).pipe(Effect.withSpan("cli.acp.turn.ask")),
)
})
ACPPermission.reply(
{
client: input.client,
connection: input.connection,
event: ask.event,
sessionID: ask.event.data.sessionID,
clientSessionID: ctx.sessionID,
cwd: ctx.cwd,
tool: ask.tool,
child: ask.child,
settled: Deferred.await(settled),
},
Deferred.await(subscription.cancelled),
)
const interpret = (turn: Turn, output: ACPTranslate.Output) => {
const interpret = (subscription: Subscription, ctx: ACPTranslate.Context, output: ACPTranslate.Output) => {
switch (output._tag) {
case "SessionUpdate":
if (turn.background) return Effect.void
return input.connection.sessionUpdate({ sessionId: turn.ctx.sessionID, update: output.update })
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
case "ChildUpdate":
return input.connection
.extNotification(ACPChild.UpdateMethod, output.update)
@@ -146,79 +147,65 @@ export const make = Effect.fnUntraced(function* (input: {
: Effect.logWarning("ACP child session update failed", cause),
),
)
case "PermissionAsk": {
const permission = {
client: input.client,
connection: input.connection,
event: output.event,
sessionID: output.event.data.sessionID,
clientSessionID: turn.ctx.sessionID,
cwd: turn.ctx.cwd,
tool: output.tool,
child: output.child,
}
return queueAsk(turn, output.event.data.id, ACPPermission.ask(permission), "reject", (decision) =>
ACPPermission.respond(permission, decision),
)
}
case "PermissionAsk":
return Effect.gen(function* () {
const settled = yield* Deferred.make<void>()
subscription.settled.set(output.event.data.id, settled)
yield* Queue.offer(subscription.asks, reply(subscription, ctx, output, settled))
})
case "FormAsk":
return Effect.gen(function* () {
const capabilities = yield* Ref.get(input.capabilities)
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
const elicitation = {
client: input.client,
connection: input.connection,
form: output.form,
requestedSchema,
clientSessionID: turn.ctx.sessionID,
child: output.child,
toolCallSent: !turn.background && (!output.child || !turn.ctx.childUpdates),
}
yield* queueAsk(turn, output.form.id, ACPElicitation.ask(elicitation), "cancel", (outcome) =>
ACPElicitation.respond(elicitation, outcome),
const settled = yield* Deferred.make<void>()
subscription.settled.set(output.form.id, settled)
yield* Queue.offer(
subscription.asks,
ACPElicitation.reply(
{
client: input.client,
connection: input.connection,
form: output.form,
requestedSchema,
clientSessionID: ctx.sessionID,
child: output.child,
toolCallSent: output.toolCallSent,
settled: Deferred.await(settled),
},
Deferred.await(subscription.cancelled),
),
)
})
case "AskSettled":
return Effect.suspend(() => {
const settled = turn.subscription.settled.get(output.id)
turn.subscription.settled.delete(output.id)
const settled = subscription.settled.get(output.id)
subscription.settled.delete(output.id)
return settled ? Deferred.succeed(settled, undefined) : Effect.void
})
}
}
const advance = Effect.fnUntraced(function* (turn: Turn) {
const event = yield* take(turn.subscription)
const folded = yield* Ref.modify(turn.state, (current) => {
const next =
turn.background && !ACPTranslate.fromTrackedChild(current, event)
? { state: current, outputs: [] }
: ACPTranslate.fold(current, event, turn.ctx)
return [next, next.state]
})
yield* Effect.forEach(folded.outputs, (output) => interpret(turn, output), { discard: true })
return folded
})
const consume = Effect.fnUntraced(function* (turn: Turn) {
const consume = Effect.fnUntraced(function* (
subscription: Subscription,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
) {
while (true) {
const folded = yield* advance(turn)
if (folded.terminal) {
yield* asksSettled(turn.subscription)
return folded.terminal
const event = yield* take(subscription)
const next = yield* Ref.modify(state, (current) => {
const step = ACPTranslate.step(current, event, ctx)
return [step, step.state]
})
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
if (next.terminal) {
yield* asksSettled(subscription)
return next.terminal
}
}
})
const followChildren = Effect.fnUntraced(function* (turn: Turn) {
while (true) {
const folded = yield* advance(turn)
if (folded.state.openChildren.size === 0) return yield* asksSettled(turn.subscription)
}
})
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: ACPPrompt.Prepared) {
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
const sessionID = attached.id
if (prompt.synthetic.length > 0) {
yield* input.client.session
@@ -254,64 +241,80 @@ export const make = Effect.fnUntraced(function* (input: {
})
const windDown = Effect.fnUntraced(function* (
turn: Turn,
subscription: Subscription,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
events: Fiber.Fiber<ACPTranslate.Terminal, ACPError.Failure>,
) {
yield* Deferred.succeed(turn.cancelled, undefined)
yield* Deferred.succeed(subscription.cancelled, undefined)
yield* input.client.session
.interrupt({ sessionID: turn.ctx.sessionID })
.interrupt({ sessionID: ctx.sessionID })
.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP server interrupt failed", cause),
),
)
if (!(yield* Ref.get(turn.state)).started) return
if (!(yield* Ref.get(state)).started) return
if (Option.exists(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)), Exit.isSuccess)) return
yield* Fiber.interrupt(events)
const abandoned = ACPTranslate.abandon(yield* Ref.get(turn.state), turn.ctx)
yield* Ref.set(turn.state, abandoned.state)
yield* Effect.forEach(abandoned.outputs, (output) => interpret(turn, output), { discard: true }).pipe(Effect.ignore)
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
yield* Ref.set(state, abandoned.state)
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
Effect.ignore,
)
})
const execute = (attached: Attached, prompt: ACPPrompt.Prepared, turn: Turn) =>
Effect.gen(function* () {
// The feed opens with `server.connected`, so every event the submission causes comes after it.
const connected = yield* take(turn.subscription)
if (connected.type !== "server.connected")
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
const events = yield* consume(turn).pipe(Effect.forkScoped)
return yield* Effect.gen(function* () {
yield* submit(attached, prompt)
if (prompt.command) return "succeeded" as const
return yield* Fiber.join(events)
}).pipe(Effect.onInterrupt(() => windDown(turn, events)))
}).pipe(Effect.scoped)
const execute = (
attached: Attached,
prompt: PreparedPrompt,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
) =>
Effect.acquireUseRelease(
subscribe(),
(subscription) =>
Effect.gen(function* () {
// The feed opens with `server.connected`, so every event the submission causes comes after it.
const connected = yield* take(subscription)
if (connected.type !== "server.connected")
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
const events = yield* consume(subscription, ctx, state).pipe(Effect.forkScoped)
return yield* Effect.gen(function* () {
yield* submit(attached, prompt)
if (prompt.command) return "succeeded" as const
return yield* Fiber.join(events)
}).pipe(Effect.onInterrupt(() => windDown(subscription, ctx, state, events)))
}).pipe(Effect.scoped),
(subscription, exit) => handoff(attached, subscription, ctx, state, exit),
)
const handoff = Effect.fnUntraced(function* (
attached: Attached,
turn: Turn,
subscription: Subscription,
ctx: ACPTranslate.Context,
state: Ref.Ref<ACPTranslate.TurnState>,
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
) {
const close = Scope.close(turn.subscription.scope, Exit.void)
const close = Scope.close(subscription.scope, Exit.void)
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
const state = yield* Ref.get(turn.state)
if (state.openChildren.size === 0) return yield* close
if ((yield* Ref.get(state)).openChildren.size === 0) return yield* close
// Children outlive a cancelled turn, so their asks still reach the client.
const background = followChildren({
...turn,
state: yield* Ref.make(state),
cancelled: yield* Deferred.make<void>(),
background: true,
}).pipe(Effect.ignore, Effect.ensuring(close), Effect.withSpan("cli.acp.turn.background"))
const cancelled = yield* Deferred.make<void>()
const background = consume({ ...subscription, cancelled }, { ...ctx, mode: "background" }, state).pipe(
Effect.ignore,
Effect.ensuring(close),
Effect.withSpan("cli.acp.turn.background"),
)
yield* input.sessions.fork(attached, background).pipe(Effect.catchTag("ACPSessionNotFoundError", () => close))
})
const settle = Effect.fnUntraced(function* (
attached: Attached,
current: ACPTranslate.TurnState,
state: Ref.Ref<ACPTranslate.TurnState>,
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
) {
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
const current = yield* Ref.get(state)
const failure = ACPTranslate.failure(current)
if (failure) return yield* failure
yield* sendUsageUpdate(attached, current)
@@ -343,31 +346,32 @@ export const make = Effect.fnUntraced(function* (input: {
)
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: ACPPrompt.Prepared) {
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: PreparedPrompt) {
const capabilities = yield* Ref.get(input.capabilities)
const state = yield* Ref.make(ACPTranslate.initial)
const exit = yield* Effect.acquireUseRelease(
open(
{
sessionID: attached.id,
cwd: attached.cwd,
start: prompt.start,
childUpdates: capabilities.childSessionUpdates,
compaction: capabilities.compaction,
},
state,
),
(turn) => execute(attached, prompt, turn),
(turn, exit) => handoff(attached, turn, exit),
).pipe(Effect.interruptible, Effect.exit)
return yield* settle(attached, yield* Ref.get(state), exit)
const ctx: ACPTranslate.Context = {
sessionID: attached.id,
cwd: attached.cwd,
start: prompt.start,
childUpdates: capabilities.childSessionUpdates,
compaction: capabilities.compaction,
mode: "turn",
}
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
return yield* settle(attached, state, exit)
})
return {
prompt: Effect.fnUntraced(function* (params, signal) {
const attached = yield* input.sessions.require(params.sessionId)
const catalog = yield* input.catalog.get(attached.cwd)
const prompt = yield* ACPPrompt.prepare(catalog, params.prompt)
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
}
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
concurrency: "unbounded",
})
const prompt = preparePrompt(catalog, parts, SessionMessage.ID.create())
// Synchronous, so concurrent prompts for one session cannot both register.
const turn = yield* Effect.withFiber((fiber) => {
if (FiberMap.hasUnsafe(turns, attached.id)) {
@@ -409,4 +413,40 @@ function aborted(signal: AbortSignal) {
})
}
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: SessionMessage.ID): PreparedPrompt {
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
const slash = detectSlashCommand(text)
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
return {
start:
slash && builtinCommands.get(slash.name)?.start === "compaction"
? { type: "compaction", id: messageID }
: { type: "input", id: messageID },
text,
files,
synthetic,
slash,
command,
}
}
function referenceUnreadableFile(part: PromptPart) {
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
Effect.as(part),
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
)
}
function detectSlashCommand(text: string) {
const value = text.trim()
if (!value.startsWith("/")) return undefined
const [name, ...rest] = value.slice(1).split(/\s+/)
if (!name) return undefined
return { name, args: rest.join(" ").trim() }
}
export * as ACPTurn from "./turn"
+30 -28
View File
@@ -1,13 +1,13 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service } from "@opencode/client/effect/service"
import { Service, type DiscoverOptions } from "@opencode/client/effect/service"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schema } from "effect"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
@@ -64,11 +64,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
const findIncumbent =
const incumbent =
serviceOptions !== undefined && port !== undefined
? Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (findIncumbent !== undefined && (yield* findIncumbent) !== undefined) return
if (incumbent !== undefined) return
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
if (options.mode === "service" && process.platform === "win32" && RetainedImage.installed(global.home))
yield* RetainedImage.retain(global.cache, "service")
@@ -88,7 +88,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const launch = start(
const server = yield* start(
{
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
@@ -144,29 +144,22 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
},
transform,
)
const server = yield* launch.pipe(
).pipe(
Effect.catch((error) => {
if (findIncumbent === undefined || !addressInUse(error)) return Effect.fail(error)
return Effect.gen(function* () {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
const found = yield* findIncumbent.pipe(Effect.timeoutOption(deadline - Date.now()))
if (Option.isSome(found) && found.value !== undefined) return
yield* Effect.sleep("100 millis")
if (Date.now() >= deadline) break
// Failed binds close their scope; a successful bind may take longer than this window to boot.
const server = yield* launch.pipe(Effect.catchIf(addressInUse, () => Effect.void))
if (server !== undefined) return server
}
return yield* Effect.fail(
new Error(
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
"Configure another port with `opencode service set port <port>` and start the service again.",
{ cause: error },
),
)
})
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
Effect.flatMap((found) =>
found
? Effect.void
: Effect.fail(
new Error(
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
"Configure another port with `opencode service set port <port>` and start the service again.",
{ cause: error },
),
),
),
)
}),
)
if (server === undefined) return
@@ -182,6 +175,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
Effect.filterOrFail((value) => value !== undefined),
Effect.retry(Schedule.spaced("100 millis")),
Effect.timeoutOption("15 seconds"),
)
return Option.isSome(found)
})
function serviceURL(hostname: string, port: number) {
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
}
+1 -3
View File
@@ -58,10 +58,8 @@ function isRouteNotFound(error: unknown) {
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
}
// qr-scanner decodes in a worker it creates from a blob: URL whenever the browser has no BarcodeDetector (Safari, desktop
// Chrome on Windows and Linux). Without worker-src that worker falls under script-src 'self' and never starts.
function csp(hash = "") {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:; worker-src 'self' blob:`
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
}
function cspForHtml(body: string) {
+22 -50
View File
@@ -1,41 +1,21 @@
import { describe, expect, test } from "bun:test"
import { Agent } from "@opencode/schema/agent"
import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import type { Catalog } from "../../src/acp/catalog"
import { configOptions, parseModelSelection } from "../../src/acp/config-option"
import { buildEffortSelectOption, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
const model = (providerID: string, id: string, name: string, variants: string[] = []) => ({
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make(id)),
name,
variants: variants.map((variant) => ({ id: Model.VariantID.make(variant) })),
})
const catalog: Catalog = {
models: [
model("anthropic", "claude/sonnet-4", "Claude Sonnet 4", ["default", "high", "very-high"]),
model("anthropic", "claude-haiku", "Claude Haiku"),
model("openai", "gpt-5", "GPT-5", ["minimal", "low"]),
model("test", "effort", "Effort", ["low", "default", "high"]),
],
defaultModel: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5") },
modes: [{ id: Agent.ID.make("build"), name: "Build" }],
defaultModeID: Agent.ID.make("build"),
commands: [],
}
const effortOption = (providerID: string, id: string) =>
configOptions(catalog, {
model: {
providerID: Provider.ID.make(providerID),
id: Model.ID.make(id),
variant: Model.VariantID.make("missing"),
},
}).find((option) => option.id === "effort")
const providers: ConfigOptionProvider[] = [
{
id: "anthropic",
name: "Anthropic",
models: [
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
{ id: "claude-haiku", name: "Claude Haiku" },
],
},
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
]
describe("acp config options", () => {
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
expect(effortOption("test", "effort")).toEqual({
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
@@ -48,29 +28,21 @@ describe("acp config options", () => {
{ value: "high", name: "High" },
],
})
expect(effortOption("openai", "gpt-5")?.currentValue).toBe("minimal")
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
"minimal",
)
})
test.each([
["openai/gpt-5", { providerID: Provider.ID.openai, id: Model.ID.make("gpt-5") }],
[
"openai/gpt-5/low",
{ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5"), variant: Model.VariantID.make("low") },
],
["anthropic/claude/sonnet-4", { providerID: Provider.ID.anthropic, id: Model.ID.make("claude/sonnet-4") }],
["openai/gpt-5", { model: { providerID: "openai", modelID: "gpt-5" } }],
["openai/gpt-5/low", { model: { providerID: "openai", modelID: "gpt-5" }, variant: "low" }],
["anthropic/claude/sonnet-4", { model: { providerID: "anthropic", modelID: "claude/sonnet-4" } }],
[
"anthropic/claude/sonnet-4/high",
{
providerID: Provider.ID.anthropic,
id: Model.ID.make("claude/sonnet-4"),
variant: Model.VariantID.make("high"),
},
],
[
"anthropic/claude/sonnet-4/missing",
{ providerID: Provider.ID.anthropic, id: Model.ID.make("claude/sonnet-4/missing") },
{ model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, variant: "high" },
],
["anthropic/claude/sonnet-4/missing", { model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" } }],
])("parses the model selection %s, preferring exact slash-containing model ids", (value, expected) => {
expect(parseModelSelection(value, catalog.models)).toEqual(expected)
expect(parseModelSelection(value, providers)).toEqual(expected)
})
})
+24 -5
View File
@@ -13,6 +13,7 @@ import {
durableEvent,
ephemeralEvent,
failed,
interrupted,
reasoningDelta,
stepEnded,
succeeded,
@@ -25,12 +26,13 @@ import {
} from "./wire-fixture"
const root = Session.ID.make("ses_root")
const ctx: ACPTranslate.TurnContext = {
const ctx: ACPTranslate.Context = {
sessionID: root,
cwd: "/workspace",
start: { type: "input", id: SessionMessage.ID.make("msg_input") },
childUpdates: false,
compaction: false,
mode: "turn",
}
const decodeEvent = Schema.decodeUnknownSync(OpenCodeEvent)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
@@ -69,7 +71,7 @@ const compactionMessage = (input: Record<string, unknown>) => ({
type Row = {
readonly name: string
readonly ctx?: Partial<ACPTranslate.TurnContext>
readonly ctx?: Partial<ACPTranslate.Context>
readonly events?: OpenCodeEventEncoded[]
readonly messages?: unknown[]
readonly expected: object
@@ -599,6 +601,23 @@ describe("acp turn translation", () => {
test.each(rows)("$name", (row) => {
expect(translate(row)).toMatchObject(row.expected)
})
test("ends a background consumer when its last open child settles, without session updates", () => {
const turn = run(live(childCreated("ses_a", root, "A"), childCreated("ses_b", root, "B"), succeeded(root)))
const background = { ...ctx, mode: "background" as const }
const first = run(
[textDelta(root, "msg_root", "ignored"), toolStarted("ses_a", "call_1", "read"), succeeded("ses_a")],
background,
turn.state,
)
const last = run([childCreated("ses_later", root, "Later"), interrupted("ses_b")], background, first.state)
expect(turn.terminal).toBe("succeeded")
expect(first.outputs).toEqual([])
expect(first.terminal).toBeUndefined()
expect(last.state.children.has("ses_later")).toBe(false)
expect(last.terminal).toBe("interrupted")
})
})
function translate(row: Row) {
@@ -627,21 +646,21 @@ function translate(row: Row) {
}
}
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx) {
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx, state = ACPTranslate.initial) {
return events.reduce<{
state: ACPTranslate.TurnState
outputs: ACPTranslate.Output[]
terminal?: ACPTranslate.Terminal
}>(
(acc, event, index) => {
const next = ACPTranslate.fold(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
const next = ACPTranslate.step(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
return {
state: next.state,
outputs: [...acc.outputs, ...next.outputs],
...(next.terminal ? { terminal: next.terminal } : {}),
}
},
{ state: ACPTranslate.initial, outputs: [] },
{ state, outputs: [] },
)
}
+17 -28
View File
@@ -9,7 +9,6 @@ import {
permissionAsked,
startSession,
succeeded,
textDelta,
toolCalled,
toolStarted,
toolSucceeded,
@@ -59,8 +58,6 @@ describe("acp turn events over the wire", () => {
{ childSessionId: "ses_background", type: "status", status: "created" },
{ childSessionId: "ses_child", type: "status", status: "completed" },
{ childSessionId: "ses_background", type: "status", status: "running" },
{ childSessionId: "ses_background", type: "update", update: { sessionUpdate: "agent_message_chunk" } },
{ childSessionId: "ses_background", type: "status", status: "completed" },
],
},
])(
@@ -69,43 +66,35 @@ describe("acp turn events over the wire", () => {
await using acp = await startSession({
capabilities: { childSessionUpdates: row.childSessionUpdates },
onPrompt: ({ sessionID, id }) =>
acp.server.prompts.length > 1
? turn(sessionID, id)
: turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
childCreated("ses_grandchild", "ses_child", "Deeper"),
toolStarted("ses_grandchild", "call_read", "read"),
toolCalled("ses_grandchild", "call_read", { path: "/workspace/src/index.ts" }),
permissionAsked("ses_grandchild", "perm_child", {
action: "read",
source: { type: "tool", messageID: "msg_child", id: "call_read" },
}),
toolSucceeded("ses_grandchild", "call_read", {}, "source"),
succeeded("ses_grandchild"),
childCreated("ses_background", sessionID, "Research"),
succeeded("ses_child"),
),
turn(
sessionID,
id,
childCreated("ses_child", sessionID, "Explore code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
childCreated("ses_grandchild", "ses_child", "Deeper"),
toolStarted("ses_grandchild", "call_read", "read"),
toolCalled("ses_grandchild", "call_read", { path: "/workspace/src/index.ts" }),
permissionAsked("ses_grandchild", "perm_child", {
action: "read",
source: { type: "tool", messageID: "msg_child", id: "call_read" },
}),
toolSucceeded("ses_grandchild", "call_read", {}, "source"),
succeeded("ses_grandchild"),
childCreated("ses_background", sessionID, "Research"),
succeeded("ses_child"),
),
permission: () => ({ outcome: { outcome: "selected", optionId: "once" } }),
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
const responded = turnUpdates(acp.updates).length
acp.server.send(
durableEvent("session.execution.started", { sessionID: "ses_background" }),
permissionAsked("ses_background", "perm_background", {
action: "read",
metadata: { path: "/workspace/notes.md" },
}),
textDelta("ses_background", "msg_background", "late"),
succeeded("ses_background"),
permissionAsked("ses_background", "perm_after_end", { action: "read" }),
)
await acp.until(() => acp.server.replies.length === 2, "child permission replies")
expect(turnUpdates(acp.updates).slice(responded)).toEqual([])
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
expect(turnUpdates(acp.updates).map((item) => item.update)).toMatchObject(row.parent)
expect(acp.childUpdates).toMatchObject(row.child)
+1 -72
View File
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
import { Global } from "@opencode/util/global"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Schedule, Schema } from "effect"
import { Effect, FileSystem, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -403,77 +403,6 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
}
}, 30_000)
test("the original managed service contender binds when the occupied port is released", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-retry-"))
const recognizing = Promise.withResolvers<void>()
const requests: string[] = []
using listener = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
requests.push(new URL(request.url).pathname)
if (requests.length === 2) recognizing.resolve()
return Response.json({ unrelated: true })
},
})
const port = listener.port
if (port === undefined) throw new Error("Server did not bind a port")
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config"), { recursive: true })
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(path.join(root, "config", "service-local.json"), JSON.stringify({ port }))
await fs.writeFile(
registration,
JSON.stringify({
id: "stale",
version: OPENCODE_VERSION,
url: "http://127.0.0.1:1",
pid: 2_147_483_647,
password: "stale",
}),
)
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: isolatedEnv(root),
stderr: "pipe",
stdout: "ignore",
})
const stderr = new Response(contender.stderr).text()
try {
// The first probe is preflight; the second only happens after start() fails to bind.
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
expect(requests).toEqual(["/api/info", "/api/info"])
await listener.stop(true)
const info = await Promise.race([
waitForInfo(registration, (info) => info.pid === contender.pid),
contender.exited.then(() => undefined),
])
expect(info?.pid, contender.exitCode === null ? undefined : await stderr).toBe(contender.pid)
const endpoint = await Effect.runPromise(
Service.discover({ file: registration }).pipe(
Effect.filterOrFail((value) => value !== undefined),
Effect.retry({ times: 400, schedule: Schedule.spaced("50 millis") }),
Effect.provide(NodeFileSystem.layer),
),
)
expect(new URL(endpoint.url).port).toBe(String(port))
expect(
await fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }).then((response) =>
response.json(),
),
).toMatchObject({ pid: contender.pid, version: OPENCODE_VERSION, urls: [endpoint.url] })
expect(contender.exitCode).toBe(null)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
expect(await waitForExit(contender)).toBe(true)
expect(await Bun.file(registration).exists()).toBe(false)
await expectPortAvailable(port)
} finally {
contender.kill("SIGTERM")
await contender.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 45_000)
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
const recognizing = Promise.withResolvers<void>()
-1
View File
@@ -171,7 +171,6 @@ describe("web UI", () => {
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
expect(fallback.headers.get("content-security-policy")).toContain("worker-src 'self' blob:")
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
expect(dotted.status).toBe(200)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"repository": {
+100 -27
View File
@@ -1,5 +1,7 @@
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
contenderFinished,
@@ -9,10 +11,8 @@ import {
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
import { fallback, headers, type LocalService, probeResult, same } from "../service-probe.js"
export * from "../service.js"
export { headers }
/** Contents of the local service registration file. */
export type Info = import("../service.js").Info
@@ -40,9 +40,9 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
options: DiscoverOptions & { readonly url: string },
) {
const info = yield* read(options.file)
if (info === undefined) return undefined
const found = (yield* Effect.promise(() => probeResult({ ...info, url: options.url }))).service
if (!found?.compatible) return undefined
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
if (found === undefined) return undefined
if (!found.compatible) return undefined
if (!matchesVersion(found.version, options)) return undefined
return { endpoint: found.endpoint, state: found.state }
})
@@ -58,7 +58,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
let failure: Error | undefined
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@@ -90,13 +89,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
yield* terminate(info, options, timing)
for (const item of contenders) {
if (item.child.pid === info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -125,27 +117,19 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
for (const item of contenders) {
if (item.child.pid === service.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished)
failure ??= finished.map(contenderFailure).find((error): error is Error => error !== undefined)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
// survivors finish without recruiting replacements that could hide the error indefinitely.
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing")
contenders.add(yield* spawnContender)
lastSpawn = Date.now()
@@ -161,7 +145,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
)
if (Option.isNone(found))
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
return found.value.endpoint
})
@@ -177,6 +161,17 @@ export const stop = Effect.fn("service.stop")(function* (options: StopOptions =
if (info !== undefined) yield* terminate(info, options, ensureTiming(options))
})
function fallback() {
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
return join(state, "opencode", "service.json")
}
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
}
/** Schema for the local service registration file. */
export const Info = Schema.Struct({
id: Schema.optional(Schema.String),
@@ -187,6 +182,13 @@ export const Info = Schema.Struct({
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeInfo = Schema.decodeUnknownOption(
Schema.Struct({
version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}),
)
// A missing or corrupt file means no valid info; callers treat both
// the same (the registering server self-evicts, clients rediscover).
const read = Effect.fnUntraced(function* (file?: string) {
@@ -196,10 +198,77 @@ const read = Effect.fnUntraced(function* (file?: string) {
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
})
type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
readonly state: "ready" | "waiting" | "failed"
readonly compatible: boolean
}
const probe = Effect.fnUntraced(function* (info: Info) {
return (yield* probeResult(info)).service
})
const probeResult = Effect.fnUntraced(function* (info: Info, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
auth:
info.password === undefined
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = yield* Effect.promise(() =>
fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
.then(async (response) => ({
response,
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
}))
.then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
),
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// A missing health endpoint identifies protocol incompatibility, not an older
// version. Only an unmet version requirement lets ensure replace this owner.
if (response.status === 404)
return {
service: {
info,
endpoint,
version: info.version,
state: "ready" as const,
compatible: false,
} satisfies LocalService,
timedOut: false,
}
const body = result.value.body
const serverInfo = decodeInfo(body)
if (Option.isSome(serverInfo)) {
if (serverInfo.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && serverInfo.value.version !== info.version)
return { service: undefined, timedOut: false }
return {
service: {
info,
endpoint,
version: serverInfo.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
timedOut: false,
}
}
return { service: undefined, timedOut: false }
})
const registered = Effect.fnUntraced(function* (file?: string, timeout?: number) {
const info = yield* read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(yield* Effect.promise(() => probeResult(info, timeout))) }
return { info, ...(yield* probeResult(info, timeout)) }
})
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
@@ -218,6 +287,10 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
+89 -22
View File
@@ -1,4 +1,6 @@
import { readFile, rm } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
@@ -9,10 +11,8 @@ import {
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
import { fallback, headers, probeResult, same } from "../service-probe.js"
export * from "../service.js"
export { headers }
// Find, start, and stop the local opencode background service.
//
@@ -38,7 +38,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
let failure: Error | undefined
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -57,7 +56,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
try {
while (true) {
if (Date.now() >= deadline) throw failure ?? new Error("Timed out waiting for the background service to start")
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, timing.requestTimeout)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
@@ -69,13 +68,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
await terminate(registration.info, options, timing)
for (const item of contenders) {
if (item.child.pid === registration.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -103,27 +95,19 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
for (const item of contenders) {
if (item.child.pid === service.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
}
} else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished)
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) throw failure
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
// survivors finish without recruiting replacements that could hide the error indefinitely.
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
contenders.add(await spawnContender())
lastSpawn = Date.now()
@@ -148,6 +132,18 @@ export async function stop(options: StopOptions = {}) {
if (info !== undefined) await terminate(info, options, ensureTiming(options))
}
function fallback() {
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
}
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined
return {
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
}
}
async function read(file?: string) {
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
if (text === undefined) return undefined
@@ -158,6 +154,73 @@ async function read(file?: string) {
}
}
type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
readonly state: "ready" | "waiting" | "failed"
readonly compatible: boolean
}
async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
auth:
info.password === undefined
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
.then(async (response) => ({
response,
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
}))
.then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// A missing health endpoint identifies protocol incompatibility, not an older
// version. Only an unmet version requirement lets ensure replace this owner.
if (response.status === 404)
return {
service: {
info,
endpoint,
version: info.version,
state: "ready" as const,
compatible: false,
} satisfies LocalService,
timedOut: false,
}
const serverInfo = decodeInfo(result.value.body)
if (serverInfo !== undefined) {
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && serverInfo.version !== info.version)
return { service: undefined, timedOut: false }
return {
service: {
info,
endpoint,
version: serverInfo.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
timedOut: false,
}
}
return { service: undefined, timedOut: false }
}
function decodeInfo(input: unknown) {
if (typeof input !== "object" || input === null) return
if (!("version" in input) || typeof input.version !== "string") return
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
return { version: input.version, pid: input.pid }
}
async function registered(file?: string, timeout?: number) {
const info = await read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
@@ -187,6 +250,10 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
return false
}
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
-84
View File
@@ -1,84 +0,0 @@
import { homedir } from "node:os"
import { join } from "node:path"
import type { Endpoint, Info } from "./service.js"
import { defaultEnsureTiming } from "./service-timing.js"
// Shared by the Effect and Promise clients. Keep this module free of Effect so the
// Promise client never loads the Effect runtime.
export type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
readonly state: "ready" | "waiting" | "failed"
readonly compatible: boolean
}
/** The default registration file. */
export function fallback() {
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
}
/** Whether two registrations describe the same service instance. */
export function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
/** Ask a registered owner for its health and classify the answer. */
export async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
auth: info.password === undefined ? undefined : { type: "basic", username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
.then(async (response) => ({
response,
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
}))
.catch(() => undefined)
if (result === undefined) return { service: undefined, timedOut: signal.aborted }
const response = result.response
// A missing health endpoint identifies protocol incompatibility, not an older
// version. Only an unmet version requirement lets ensure replace this owner.
if (response.status === 404)
return {
service: {
info,
endpoint,
version: info.version,
state: "ready",
compatible: false,
} satisfies LocalService,
timedOut: false,
}
const serverInfo = decodeInfo(result.body)
if (serverInfo === undefined) return { service: undefined, timedOut: false }
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && serverInfo.version !== info.version) return { service: undefined, timedOut: false }
return {
service: {
info,
endpoint,
version: serverInfo.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
timedOut: false,
}
}
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined
return {
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
}
}
function decodeInfo(input: unknown) {
if (typeof input !== "object" || input === null) return
if (!("version" in input) || typeof input.version !== "string") return
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
return { version: input.version, pid: input.pid }
}
@@ -1,4 +1,4 @@
import { mkdtemp, rename, rm } from "node:fs/promises"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { waitForExit } from "./service-timing"
@@ -8,16 +8,6 @@ export async function serviceFixture() {
const registration = join(directory, "service.json")
const processes: Bun.Subprocess[] = []
const pids = new Set<number>()
const starts = async () =>
(
await Bun.file(registration + ".starts")
.text()
.catch(() => "")
)
.trim()
.split("\n")
.filter(Boolean)
.map(Number)
const command = (mode: string, ...args: string[]) => [
process.execPath,
join(import.meta.dir, "service.ts"),
@@ -30,21 +20,6 @@ export async function serviceFixture() {
directory,
registration,
command,
starts,
async waitForStarts(count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const result = await starts()
if (result.length >= count) return result
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} contenders`)
},
async release(pid: number, action: "fail" | "hang" | "ready") {
const file = registration + `.release-${pid}`
await Bun.write(file + ".tmp", action)
await rename(file + ".tmp", file)
if (action === "fail") await waitForExit(pid)
},
spawn(mode: string, ...args: string[]) {
const subprocess = Bun.spawn(command(mode, ...args), { stdout: "ignore", stderr: "inherit" })
processes.push(subprocess)
@@ -72,8 +47,6 @@ export async function serviceFixture() {
throw new Error(`Timed out waiting for ${file}`)
},
async [Symbol.asyncDispose]() {
// Include detached contenders that never registered, even when an assertion fails.
for (const pid of await starts()) pids.add(pid)
await Promise.all([
...processes.map(async (subprocess) => {
subprocess.kill("SIGTERM")
+3 -17
View File
@@ -17,18 +17,6 @@ if (mode === "environment") {
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
let controlled = ""
if (mode === "controlled") {
await appendFile(registration + ".starts", process.pid + "\n")
const release = registration + `.release-${process.pid}`
while (!(await Bun.file(release).exists())) await Bun.sleep(5)
controlled = await Bun.file(release).text()
if (controlled === "fail") {
process.stderr.write("actionable startup failure: storage initialization denied\n")
process.exit(23)
}
}
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
await appendFile(registration + ".starts", process.pid + "\n")
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
@@ -75,7 +63,7 @@ const server = Bun.serve({
return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
if (mode === "hanging" || controlled === "hang") {
if (mode === "hanging") {
await appendFile(registration + ".requests", process.pid + "\n")
return new Promise<Response>(() => {})
}
@@ -104,10 +92,8 @@ const server = Bun.serve({
})
// Install handlers before publishing: a test may signal as soon as the registration appears.
if (controlled !== "hang") {
process.on("SIGTERM", () => void shutdown("SIGTERM"))
process.on("SIGINT", () => void shutdown("SIGINT"))
}
process.on("SIGTERM", () => void shutdown("SIGTERM"))
process.on("SIGINT", () => void shutdown("SIGINT"))
await writeFile(
registration + ".tmp",
@@ -112,76 +112,6 @@ test("reports a failed registered service", async () => {
)
})
test("reports overlapping contender failures without recruiting replacements", async () => {
await using fixture = await serviceFixture()
const started = Date.now()
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
(error: unknown) => error,
)
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
await Bun.sleep(450)
await fixture.release(second, "fail")
const error = await pending
expect(Date.now() - started).toBeLessThan(3_000)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("Server process exited with code 23")
expect(error.message).toContain("storage initialization denied")
expect(await fixture.starts()).toHaveLength(2)
})
test("retains a contender failure until the deadline while its survivor stalls", async () => {
await using fixture = await serviceFixture()
const started = Date.now()
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
(error: unknown) => error,
)
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
const error = await pending
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("Server process exited with code 23")
expect(error.message).toContain("storage initialization denied")
expect(await fixture.starts()).toHaveLength(2)
expect(() => process.kill(second, 0)).not.toThrow()
})
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
await using fixture = await serviceFixture()
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
await Bun.sleep(450)
await fixture.release(second, "ready")
const endpoint = await pending
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
expect(await fixture.starts()).toHaveLength(2)
})
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
await using fixture = await serviceFixture()
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(second, "hang")
await fixture.waitForFile()
await fixture.release(first, "fail")
const [, , third] = await fixture.waitForStarts(3)
await fixture.release(third, "ready")
const endpoint = await pending
fixture.track(third)
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
})
test("reports a bounded contender stderr tail with native promises", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
-70
View File
@@ -290,76 +290,6 @@ test("reports a contender that fails to start", async () => {
).rejects.toThrow("Server process exited with code 1")
})
test("reports overlapping contender failures without recruiting replacements", async () => {
await using fixture = await serviceFixture()
const started = Date.now()
const pending = run(
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
).catch((error: unknown) => error)
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
await Bun.sleep(450)
await fixture.release(second, "fail")
const error = await pending
expect(Date.now() - started).toBeLessThan(3_000)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("Server process exited with code 23")
expect(error.message).toContain("storage initialization denied")
expect(await fixture.starts()).toHaveLength(2)
})
test("retains a contender failure until the deadline while its survivor stalls", async () => {
await using fixture = await serviceFixture()
const started = Date.now()
const pending = run(
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
).catch((error: unknown) => error)
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
const error = await pending
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toContain("Server process exited with code 23")
expect(error.message).toContain("storage initialization denied")
expect(await fixture.starts()).toHaveLength(2)
expect(() => process.kill(second, 0)).not.toThrow()
})
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
await using fixture = await serviceFixture()
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(first, "fail")
await Bun.sleep(450)
await fixture.release(second, "ready")
const endpoint = await pending
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
expect(await fixture.starts()).toHaveLength(2)
})
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
await using fixture = await serviceFixture()
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
const [first, second] = await fixture.waitForStarts(2)
await fixture.release(second, "hang")
await fixture.waitForFile()
await fixture.release(first, "fail")
const [, , third] = await fixture.waitForStarts(3)
await fixture.release(third, "ready")
const endpoint = await pending
fixture.track(third)
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
})
test("reports a bounded contender stderr tail", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.23",
"version": "2.0.22",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.23",
"version": "2.0.22",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.23",
"version": "2.0.22",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.23",
"version": "2.0.22",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
@@ -3,7 +3,6 @@ export * as ConfigInstructionPlugin from "./instruction.js"
import { define } from "@opencode/plugin/effect/plugin"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { sameDirectory } from "@opencode/util/path"
import { dirname, join } from "path"
import { Effect, PubSub, Semaphore, Stream } from "effect"
import { Watcher } from "../../filesystem/watcher.js"
@@ -134,10 +133,7 @@ export const Plugin = define({
}),
})
// `start` keeps the client's spelling while `stop` may come from git, so a Windows drive
// letter can differ only in case (`c:\repo` vs `C:\repo`). Compare the way FSUtil.contains
// admitted `start` beneath `stop`, or the walk passes `stop` and recurses at the drive root.
function ancestorDirectories(start: string, stop: string): string[] {
if (sameDirectory(start, stop)) return [start]
if (start === stop) return [start]
return [start, ...ancestorDirectories(dirname(start), stop)]
}
+1 -1
View File
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
+5 -10
View File
@@ -7,19 +7,13 @@ import { Permission } from "../permission.js"
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your strengths:
- Rapidly finding files using glob patterns
- Searching code and text with powerful regex patterns
- Reading and analyzing file contents
Guidelines:
- Use Glob for broad file pattern matching
- Use Grep for searching file contents with regex
- Use Read when you know the specific file path you need to read
- Your role is EXCLUSIVELY to search and analyze
- Parallelize independent tool calls for searches and reads whenever possible
- Adapt your search approach based on the thoroughness level specified by the caller
- Return file paths as absolute paths in your final response
- For clear communication, avoid using emojis
- Do not create any files, or run bash commands that modify the user's system state in any way
- You MUST NOT create, modify, delete, move, or copy files, including temporary files and reports
- Shell commands MUST be read-only. NEVER run commands that write files or change system state
Complete the user's search request efficiently and report your findings clearly.`
@@ -115,6 +109,7 @@ export const Plugin = define({
...Permission.merge(
[
{ action: "*", resource: "*", effect: "deny" },
{ action: "shell", resource: "*", effect: "allow" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
@@ -13,8 +13,7 @@ export function text(message: SessionMessage.Info | undefined) {
message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.filter((text) => text.length > 0)
.join("\n\n") || NO_TEXT
.join("") || NO_TEXT
)
}
+2
View File
@@ -203,6 +203,8 @@ describe("Agent", () => {
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
const explore = yield* agent.get(Agent.ID.make("explore"))
expect(Permission.evaluate("shell", "git log -5", explore?.permissions ?? []).effect).toBe("allow")
expect(Permission.evaluate("edit", "src/index.ts", explore?.permissions ?? []).effect).toBe("deny")
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
@@ -364,53 +364,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
// A client CWD can spell the drive `c:` while git reports the project root as `C:`.
;(process.platform === "win32" ? it.live : it.live.skip)(
"stops at the project root when the location drive letter differs in case",
() =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(tmp.path, "home")
const project = path.join(tmp.path, "repo")
const directory = project[0].toLowerCase() + project.slice(1)
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(path.join(project, "AGENTS.md"), "project"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "AGENTS.md"), "outside"))
const discovery = yield* start()
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(global, "AGENTS.md"), type: "file" },
{ path: path.join(directory, "AGENTS.md"), type: "file" },
])
expect((yield* readInitial(yield* discovery.load())).text).toBe(
`Instructions from: ${path.join(directory, "AGENTS.md")}\nproject`,
)
}).pipe(
Effect.provide(
instructionLayer({
config: global,
home,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
it.effect("isolates source failure without failing activation", () => {
const failingFS = Layer.effect(
FSUtil.Service,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -32
View File
@@ -4,26 +4,20 @@ import { Script } from "@opencode/script"
import { UpdateArtifact } from "../../../script/update-artifact"
const dryRun = process.argv.includes("--dry-run")
if (!Script.release) {
console.log("skipped desktop publication without a release")
process.exit(0)
}
const directory = process.env.OPENCODE_DESKTOP_DIST
if (!directory) throw new Error("OPENCODE_DESKTOP_DIST is required")
const files = (
await Array.fromAsync(
new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ cwd: directory, absolute: true }),
)
).sort()
if (!files.length) throw new Error("No desktop release files found")
const uploaded = await UpdateArtifact.upload({ version: Script.version, files, dryRun })
const artifact = {
channel: Script.channel,
name: "desktop",
@@ -31,9 +25,7 @@ const artifact = {
version: Script.version,
metadata: { files: uploaded, ...(await metadata(Script.version, uploaded)) },
}
if (dryRun) console.log(`dry-run artifact: ${JSON.stringify(artifact)}`)
if (!dryRun) await UpdateArtifact.publish(artifact)
type DesktopFile = {
@@ -45,9 +37,7 @@ type DesktopFile = {
async function metadata(version: string, files: Record<string, { url: string }>) {
const directory = process.env.LATEST_YML_DIR
if (!directory) throw new Error("LATEST_YML_DIR is required")
const entries = await Promise.all(
[
{
@@ -74,75 +64,54 @@ async function metadata(version: string, files: Record<string, { url: string }>)
await Promise.all(
item.sources.map(async ([subdirectory, source]) => {
const file = Bun.file(`${directory}/${subdirectory}/${source}`)
if (!(await file.exists())) return undefined
return parse(await file.text(), version, files)
}),
)
).filter((manifest) => manifest !== undefined)
if (manifests.length !== item.sources.length) return undefined
return [
item.name,
{ files: manifests.flatMap((manifest) => manifest.files), releaseDate: manifests[0]!.releaseDate },
] as const
}),
)
if (entries.some((entry) => entry === undefined)) throw new Error("Desktop update metadata is incomplete")
const manifests = Object.fromEntries(entries.filter((entry) => entry !== undefined))
return { manifests }
}
function parse(content: string, version: string, uploaded: Record<string, { url: string }>) {
const lines = content.split("\n")
const found = lines
.find((line) => line.startsWith("version:"))
?.slice("version:".length)
.trim()
if (found !== version) throw new Error(`Desktop metadata version mismatch: expected ${version}, got ${found}`)
const releaseDate = lines
.find((line) => line.startsWith("releaseDate:"))
?.slice("releaseDate:".length)
.trim()
.replace(/^['"]|['"]$/g, "")
if (!releaseDate) throw new Error("Desktop metadata did not include a release date")
const files: DesktopFile[] = []
lines.forEach((line) => {
const value = line.trim()
if (value.startsWith("- url:")) {
const name = value.slice("- url:".length).trim()
const filename = name.startsWith("http") ? decodeURIComponent(new URL(name).pathname.split("/").pop()!) : name
const url = uploaded[filename]?.url
if (!url) throw new Error(`Desktop update file was not uploaded: ${filename}`)
files.push({ url, sha512: "", size: 0 })
return
}
const current = files.at(-1)
// electron-builder repeats the first file's sha512 as a top-level legacy field after the list.
if (!current || !line.startsWith(" ")) return
if (!current) return
if (value.startsWith("sha512:")) current.sha512 = value.slice("sha512:".length).trim()
if (value.startsWith("size:")) current.size = Number(value.slice("size:".length).trim())
if (value.startsWith("blockMapSize:")) current.blockMapSize = Number(value.slice("blockMapSize:".length).trim())
})
if (!files.length || files.some((file) => !file.sha512 || !file.size))
throw new Error("Desktop metadata contained an incomplete file")
return { files, releaseDate }
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.23",
"version": "2.0.22",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/gui-extensions",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"license": "MIT",
+1 -12
View File
@@ -1,18 +1,7 @@
import { Schema } from "effect"
import { Extension, Store } from "../sdk"
import { Extension } from "../sdk"
import en from "./i18n/en"
const Chats = Schema.Struct({
chats: Schema.Array(
Schema.Struct({ id: Schema.String, question: Schema.String, answer: Schema.optional(Schema.String) }),
),
})
export default Extension.define({
id: "btw",
stores: {
// Each open /btw tab's chat (its question and answer), kept until its tab closes.
chats: Store.session(Chats, { chats: [] }),
},
i18n: { en },
})
+65 -76
View File
@@ -1,7 +1,7 @@
import { batch, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { showToast } from "@opencode/ui/toast"
import type { MountedSession, SetupContext } from "../sdk"
import { createKeyed, type MountedSession, type SetupContext } from "../sdk"
import type Btw from "./index"
const instructions = [
@@ -10,56 +10,38 @@ const instructions = [
"Do not call any tools and do not take any actions.",
].join(" ")
/** One-shot side questions per session, one tab each, stored until the tab closes. */
const empty = {
question: "",
answer: "",
error: false,
pending: false,
}
/** Side questions per session. Window-local: a reload drops them, and with them the tab. */
export function createBtw(ctx: SetupContext<typeof Btw>) {
const sessions = ctx.sessions
const layout = ctx.layout
const [states, setStates] = createStore<Record<string, typeof empty>>({})
const requests = new Map<string, number>()
const controllers = new Map<string, AbortController>()
const [requests, setRequests] = createStore<{ pending: string[] }>({ pending: [] })
const saved = (session: MountedSession) => ctx.stores.chats(session)
const stop = (id: string) => {
controllers.get(id)?.abort()
controllers.delete(id)
setRequests("pending", (list) => list.filter((item) => item !== id))
const stop = (key: string) => {
const controller = controllers.get(key)
if (!controller) return
controller.abort()
controllers.delete(key)
if (states[key]?.pending) setStates(key, { pending: false, error: true })
}
// In-flight questions keep running when the user leaves their session and answer into its store; only a reload,
// closing the question's tab or disposing the extension abandons one, which leaves it retryable.
// Leaving a session abandons its in-flight question; the same session moving to another directory does not.
createKeyed(
() => sessions.current()?.key,
(key) => onCleanup(() => stop(key)),
)
onCleanup(() => Array.from(controllers.keys()).forEach(stop))
const entry = (session: MountedSession, id: string) => saved(session).value?.chats.find((item) => item.id === id)
const pending = (id: string) => requests.pending.includes(id)
// Takes the question rather than reading it back, because a new chat's write may still wait for the store to load.
const generate = (session: MountedSession, id: string, question: string) => {
if (pending(id)) return
const store = saved(session)
const controller = new AbortController()
controllers.set(id, controller)
setRequests("pending", (list) => [...list, id])
return (
session.server.client.session
.generate(
{ sessionID: session.id, prompt: [instructions, question].join("\n\n") },
{ signal: controller.signal },
)
.then((result) => {
if (ctx.signal.aborted || controller.signal.aborted) return
store.update((draft) => {
const chat = draft.chats.find((item) => item.id === id)
if (chat) chat.answer = result.text.trim()
})
})
// A missing answer is the retry state; no transient error flag is stored.
.catch(() => undefined)
.finally(() => {
if (controllers.get(id) === controller) stop(id)
})
)
}
const ask = (value?: string) => {
const question = value?.trim()
@@ -69,47 +51,54 @@ export function createBtw(ctx: SetupContext<typeof Btw>) {
return
}
const session = ctx.sessions.current()
const session = sessions.current()
if (!session?.id) return
const id = crypto.randomUUID()
// Desktop storage loads asynchronously. A write before load queues and lands in the same batch that marks the
// store ready, and the panel keeps a tab hidden until then, so the chat and its tab need not wait. The session's
// store lives while its tab stays open, so leaving the session first still records the chat.
const key = session.key
const request = (requests.get(key) ?? 0) + 1
requests.set(key, request)
controllers.get(key)?.abort()
const controller = new AbortController()
controllers.set(key, controller)
// The tab lists only while its session has a question, and the host drops unlisted transient keys, so
// record the question in the same batch that opens the tab.
batch(() => {
saved(session).update((draft) => {
draft.chats.push({ id, question })
})
ctx.layout.open(`${ctx.id}:${id}`, session, { tab: "select" })
setStates(key, { question, answer: "", error: false, pending: true })
layout.open(`${ctx.id}:main`, session)
})
void generate(session, id, question)
return session.server.client.session
.generate(
{
sessionID: session.id,
prompt: [instructions, question].join("\n\n"),
},
{ signal: controller.signal },
)
.then((result) => {
if (controller.signal.aborted || requests.get(key) !== request) return
setStates(key, { answer: result.text.trim(), pending: false })
})
.catch(() => {
if (controller.signal.aborted || requests.get(key) !== request) return
setStates(key, { error: true, pending: false })
})
.finally(() => {
if (controllers.get(key) === controller) controllers.delete(key)
})
}
const state = (session: MountedSession) => states[session.key]
return {
ask,
saved,
pending,
question: (session: MountedSession, id: string) => entry(session, id)?.question,
answer: (session: MountedSession, id: string) => entry(session, id)?.answer,
error: (session: MountedSession, id: string) => {
const item = entry(session, id)
return !!item && item.answer === undefined && !pending(id)
},
retry: (session: MountedSession, id: string) => {
const question = entry(session, id)?.question
if (question) void generate(session, id, question)
},
/** Closing a tab forgets its question and answer. */
remove: (session: MountedSession, id: string) => {
stop(id)
saved(session).update((draft) => {
draft.chats = draft.chats.filter((item) => item.id !== id)
})
},
has: (session: MountedSession) => !!state(session),
answer: (session: MountedSession) => (state(session) ?? empty).answer,
error: (session: MountedSession) => (state(session) ?? empty).error,
pending: (session: MountedSession) => (state(session) ?? empty).pending,
question: (session: MountedSession) => (state(session) ?? empty).question,
retry: (session: MountedSession) => ask((state(session) ?? empty).question),
}
}
+11 -13
View File
@@ -10,10 +10,10 @@ import { Markdown } from "@opencode/session-ui/markdown"
import { useExtension, type MountedSession } from "../sdk"
import type { BtwModel } from "./model"
export default function SessionBtwPanel(props: { btw: BtwModel; session: MountedSession; id: string }) {
export default function SessionBtwPanel(props: { btw: BtwModel; session: MountedSession }) {
const ctx = useExtension()
const system = ctx.system
const answer = () => props.btw.answer(props.session, props.id)
const answer = () => props.btw.answer(props.session)
// A new token for each answer, so the copied mark clears when the answer changes.
const shown = createMemo(on(answer, () => ({})))
const [copiedAnswer, setCopiedAnswer] = createSignal<object>()
@@ -32,7 +32,7 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
return (
<div class="flex h-full min-h-0 flex-col bg-v2-background-bg-base" data-slot="session-btw-panel">
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-v2-border-border-base px-5 py-4">
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question(props.session, props.id)}</div>
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question(props.session)}</div>
<Show when={answer()}>
<Tooltip value={copied() ? ctx.t("common.copied") : ctx.t("copy")}>
<IconButton
@@ -48,7 +48,7 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
<div class="relative min-h-0 flex-1">
<Switch>
<Match when={props.btw.pending(props.id)}>
<Match when={props.btw.pending(props.session)}>
<div
data-component="session-working"
role="status"
@@ -57,22 +57,20 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
<TextShimmer text={ctx.t("session.timeline.working")} active />
</div>
</Match>
<Match when={props.btw.error(props.session, props.id)}>
<Match when={props.btw.error(props.session)}>
<div class="flex h-full flex-col items-center justify-center gap-3 px-8 pb-24 text-center">
<div class="text-13-regular text-text-weak">{ctx.t("error")}</div>
<Button size="small" variant="outline" onClick={() => props.btw.retry(props.session, props.id)}>
<Button size="small" variant="outline" onClick={() => props.btw.retry(props.session)}>
{ctx.t("retry")}
</Button>
</div>
</Match>
<Match when={answer()}>
{(text) => (
<ScrollView class="absolute inset-0">
<div class="px-5 py-4 pb-8">
<Markdown text={text()} class="text-14-regular" />
</div>
</ScrollView>
)}
<ScrollView class="absolute inset-0">
<div class="px-5 py-4 pb-8">
<Markdown text={answer()} class="text-14-regular" />
</div>
</ScrollView>
</Match>
</Switch>
</div>
+15 -55
View File
@@ -1,6 +1,6 @@
import { createMemo, lazy, onCleanup, Suspense } from "solid-js"
import { Icon } from "@opencode/ui/icon"
import { Command, createKeyed, onIdle, Panel, type PanelTab, type Setup } from "../sdk"
import { Command, onIdle, Panel, type PanelTab, type Setup } from "../sdk"
import type Btw from "./index"
import { createBtw } from "./model"
@@ -13,19 +13,18 @@ const setup: Setup<typeof Btw> = (ctx) => {
// Changes when a session mounts or unmounts, not on every switch between sessions.
const mounted = createMemo(() => !!sessions.current())
// Tab objects per session, reused so strip updates and session switches do not rebuild a trigger.
const tabs = new Map<string, Map<string, PanelTab>>()
// Drops the tab objects of sessions whose shell tab closed.
createKeyed(
() => [...sessions.list().map((session) => session.key), sessions.current()?.key].join("\u0000"),
() => {
const keep = new Set([...sessions.list().map((session) => session.key), sessions.current()?.key])
tabs.forEach((_cache, key) => {
if (!keep.has(key)) tabs.delete(key)
})
const tab: PanelTab = {
id: "main",
get title() {
return ctx.t("tab.title")
},
)
label: () => (
<div class="flex items-center gap-1.5">
<Icon name="bubble-5" size="small" />
<span>{ctx.t("tab.title")}</span>
</div>
),
}
ctx.add(
Command,
@@ -49,52 +48,13 @@ const setup: Setup<typeof Btw> = (ctx) => {
transient: true,
// Layouts saved before extensions store the tab as "btw"; as a panel key it leaves like any unlisted transient tab.
legacy: { btw: "main" },
// One tab per stored question. Until the session's store loads, its tabs stay listed but hidden so restore keeps
// them; afterwards a tab without a stored question leaves the strip.
list: (input) => {
if (input.open.length === 0) return []
const saved = btw.saved(input.session)
const cache = tabs.get(input.session.key) ?? new Map<string, PanelTab>()
tabs.set(input.session.key, cache)
return input.open.flatMap((id) => {
if (saved.ready() && !saved.value?.chats.some((item) => item.id === id)) return []
const existing = cache.get(id)
if (existing) return [existing]
const tab: PanelTab = {
id,
get title() {
return saved.value?.chats.find((item) => item.id === id)?.question ?? ctx.t("tab.title")
},
get hidden() {
return !saved.ready()
},
label: (state) => (
<div class="flex min-w-0 items-center gap-1.5">
<Icon name="bubble-5" size="small" />
<span class="truncate">{btw.question(state.session, id) ?? ctx.t("tab.title")}</span>
</div>
),
}
cache.set(id, tab)
return [tab]
})
},
// The answer lives only in this window's memory, so the tab lists while its session has one.
list: (input) => (input.open.includes("main") && btw.has(input.session) ? [tab] : []),
render: (props) => (
<Suspense>
<SessionBtwPanel btw={btw} session={props.session} id={props.tab.id} />
<SessionBtwPanel btw={btw} session={props.session} />
</Suspense>
),
close: (input) => {
btw.remove(input.session, input.tab.id)
tabs.get(input.session.key)?.delete(input.tab.id)
},
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.23",
"version": "2.0.22",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"exports": {
@@ -875,63 +875,6 @@ describe("FlowchartDiagram", () => {
expect(output.match(/[▼◀]/g)).toHaveLength(3)
})
test("orders rank nodes to remove crossings between branches", () => {
const content = `flowchart TD
A{Question} -->|Path one| B[Step one]
A -->|Path two| C[Step two]
B --> D{Check one}
D -->|No| E{Retry one}
E -->|No| B
C --> F{Check two}
F -->|No| G{Retry two}
G -->|No| C
E -->|Yes| H[Rethink]
G -->|Yes| H
H --> A
D -->|Yes| I([Done])
F -->|Yes| I`
const layout = layoutFlowchartDiagram(content, { compact: true, layoutMaxWidth: 120 })
const left = (id: string) => layout.bounds.get(id)!.left
expect(left("E")).toBeLessThan(left("I"))
expect(left("I")).toBeLessThan(left("G"))
expectDiagram(renderFlowchartDiagram(content, { compact: true, layoutMaxWidth: 120 })).toEqualDiagram(`
╭─────────╮
╭─╯ ╰─╮
│ Question │◀──────────────────────────╮
╰─╮ ╭─╯ │
╰────┬────╯ │
╭─ Path one ──┴── Path two ───╮ │
│ │ │
▼ ▼ │
╭──────────╮ ╭──────────╮ │
╭──── No ─────▶│ Step one │ │ Step two │◀──── No ────┤
│ ╰─────┬────╯ ╰─────┬────╯ │
│ │ │ │
│ │ ╭─╯ │
│ ▼ ▼ │
│ ╭─────────╮ ╭─────────╮ │
│ ╭─╯ ╰─╮ ╭─╯ ╰─╮ │
│ │ Check one │ │ Check two │ │
│ ╰─╮ ╭─╯ ╰─╮ ╭─╯ │
│ ╰────┬────╯ ╰────┬────╯ │
│ ╭── No ───┴──── Yes ─────┬─── Yes ────┴─── No ───╮ │
│ │ │ │ │
│ ▼ │ ▼ │
│ ╭─────────╮ ▼ ╭─────────╮ │
│ ╭─╯ ╰─╮ ╭──────╮ ╭─╯ ╰─╮ │
╰───┤ Retry one │ │ Done │ │ Retry two ├──┤
╰─╮ ╭─╯ ╰──────╯ ╰─╮ ╭─╯ │
╰────┬────╯ ╰────┬────╯ │
│ │ │
╰───────── Yes ─────────┬───────── Yes ──────────╯ │
▼ │
╭─────────╮ │
│ Rethink ├─────────────────────────────╯
╰─────────╯
`)
})
test("routes transitive horizontal shortcuts around intermediate stages", () => {
const content = `flowchart LR
A[Start] --> B[Validate]
-98
View File
@@ -381,7 +381,6 @@ function layoutRankedNodes(
nodes.push(node)
ranksByIndex.set(normalizedRank, nodes)
}
reduceRankCrossings(diagram, ranks, normalizedRanks, ranksByIndex)
const spaciousNodeGap = Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP)
const widestUnlabeledRank = Math.max(
@@ -524,103 +523,6 @@ function layoutRankedNodes(
return { bounds, wrapped }
}
const CROSSING_SWEEPS = 8
// Barycenter ordering within ranks. Source order is kept unless a sweep strictly reduces crossings, so
// crossing-free diagrams render exactly as authored.
function reduceRankCrossings(
diagram: FlowchartDiagram,
ranks: ReadonlyMap<string, number>,
normalizedRanks: ReadonlyMap<string, number>,
ranksByIndex: Map<number, FlowchartNode[]>,
): void {
// Reordering could interleave members of different subgraphs and break their frames.
if (diagram.subgraphs?.length) return
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
const layerByRank = new Map(rankKeys.map((rank, index) => [rank, index]))
// Feedback edges route around the diagram, so only forward edges between adjacent ranks can cross.
const links = diagram.edges.flatMap((edge) => {
const fromRank = ranks.get(edge.from)
const toRank = ranks.get(edge.to)
if (edge.orderOnly || fromRank === undefined || toRank === undefined || toRank !== fromRank + 1) return []
const from = normalizedRanks.get(edge.from)!
const to = normalizedRanks.get(edge.to)!
if (Math.abs(from - to) !== 1) return []
return [
from < to
? { layer: layerByRank.get(from)!, upper: edge.from, lower: edge.to }
: { layer: layerByRank.get(to)!, upper: edge.to, lower: edge.from },
]
})
const original = rankKeys.map((rank) => ranksByIndex.get(rank)!.map((node) => node.id))
const initialCrossings = layerCrossings(original, links)
if (initialCrossings === 0) return
const best = Array.from({ length: CROSSING_SWEEPS }).reduce(
(state: { current: string[][]; best: string[][]; crossings: number }, _, sweep) => {
const current = sweepLayers(state.current, links, sweep % 2 === 0)
const crossings = layerCrossings(current, links)
return crossings < state.crossings ? { current, best: current, crossings } : { ...state, current }
},
{ current: original, best: original, crossings: initialCrossings },
).best
if (best === original) return
const nodeById = new Map(diagram.nodes.map((node) => [node.id, node]))
for (const [index, rank] of rankKeys.entries()) {
ranksByIndex.set(
rank,
best[index]!.map((id) => nodeById.get(id)!),
)
}
}
function sweepLayers(
layers: readonly string[][],
links: readonly { layer: number; upper: string; lower: string }[],
down: boolean,
): string[][] {
const next = layers.map((layer) => [...layer])
const indices = next.map((_, index) => index)
for (const index of down ? indices.slice(1) : indices.slice(0, -1).reverse()) {
const fixed = next[down ? index - 1 : index + 1]!
const keys = new Map(
next[index]!.map((id, position) => {
const neighbors = links.flatMap((link) => {
if (down && link.layer === index - 1 && link.lower === id) return [fixed.indexOf(link.upper)]
if (!down && link.layer === index && link.upper === id) return [fixed.indexOf(link.lower)]
return []
})
if (neighbors.length === 0) return [id, position]
return [id, neighbors.reduce((total, neighbor) => total + neighbor, 0) / neighbors.length]
}),
)
next[index] = next[index]!.toSorted((a, b) => keys.get(a)! - keys.get(b)!)
}
return next
}
function layerCrossings(
layers: readonly string[][],
links: readonly { layer: number; upper: string; lower: string }[],
): number {
const position = new Map(layers.flatMap((layer) => layer.map((id, index) => [id, index] as const)))
return links.reduce(
(total, link, index) =>
total +
links
.slice(index + 1)
.filter(
(other) =>
other.layer === link.layer &&
(position.get(link.upper)! - position.get(other.upper)!) *
(position.get(link.lower)! - position.get(other.lower)!) <
0,
).length,
0,
)
}
function layoutLocalSubgraphDirections(
diagram: FlowchartDiagram,
nodeBounds: Map<string, FlowchartNodeBounds>,
@@ -153,28 +153,6 @@ const flowFamilies = {
],
)
},
"decision-loops"(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
profile,
["A", "B", "C", "D", "E", "F", "G", "H", "I"],
[
["A", "B", "E01"],
["A", "C", "E02"],
["B", "D", "E03"],
["D", "E", "E04"],
["E", "B", "E05"],
["C", "F", "E06"],
["F", "G", "E07"],
["G", "C", "E08"],
["E", "H", "E09"],
["G", "H", "E10"],
["H", "A", "E11"],
["D", "I", "E12"],
["F", "I", "E13"],
],
)
},
parallel(direction: FlowchartDirection, profile: LabelProfile) {
return flowSource(
direction,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.23",
"version": "2.0.22",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.23",
"version": "2.0.22",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.23",
"version": "2.0.22",
"private": true,
"type": "module",
"license": "MIT",
@@ -6,7 +6,6 @@ import { useRoute, useRouteData } from "../../../context/route"
import { useData } from "../../../context/data"
import { useClient } from "../../../context/client"
import { useTheme } from "../../../context/theme"
import { useStorage } from "../../../context/storage"
import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./context"
@@ -32,9 +31,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const shortcuts = Keymap.useShortcuts()
const session = createMemo(() => data.session.get(props.sessionID))
const [store, setStore] = createStore({ selected: 0 })
// The session route remounts on navigation, so the filter lives in TUI memory and only ctrl+a changes it.
const [filter, updateFilter] = useStorage().memory("subagents-filter", { initial: { active: true } })
const [store, setStore] = createStore({ selected: 0, active: true })
const entries = createMemo<SubagentEntry[]>(() => {
const current = session()
@@ -59,7 +56,7 @@ export function SubagentsTab(props: { sessionID: string }) {
},
)
return result.filter((entry) => (filter.active ? entry.status === "running" : entry.status !== "running"))
return result.filter((entry) => (store.active ? entry.status === "running" : entry.status !== "running"))
})
let selectedSessionID = ""
@@ -73,7 +70,7 @@ export function SubagentsTab(props: { sessionID: string }) {
if (!active) {
if (wasActive) {
selectedSessionID = ""
setStore("selected", 0)
setStore({ selected: 0, active: true })
}
wasActive = false
return
@@ -124,7 +121,7 @@ export function SubagentsTab(props: { sessionID: string }) {
? [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
: []),
{
label: `show ${filter.active ? "inactive" : "active"}`,
label: `show ${store.active ? "inactive" : "active"}`,
shortcut: shortcuts.get("composer.subagent.toggle-activity") ?? "",
},
]
@@ -175,8 +172,7 @@ export function SubagentsTab(props: { sessionID: string }) {
group: "Composer",
bind: "ctrl+a",
run() {
updateFilter((draft) => (draft.active = !draft.active))
setStore("selected", 0)
setStore({ selected: 0, active: !store.active })
scroll?.scrollTo(0)
},
},
@@ -198,7 +194,7 @@ export function SubagentsTab(props: { sessionID: string }) {
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show
when={entries().length > 0}
fallback={<text fg={theme.text.muted}> No {filter.active ? "active" : "inactive"} subagents</text>}
fallback={<text fg={theme.text.muted}> No {store.active ? "active" : "inactive"} subagents</text>}
>
<For each={entries()}>
{(entry, index) => {
@@ -126,6 +126,7 @@ function GroupContent(props: GroupProps) {
)
return `${completed() ? "Explored" : "Exploring"}: ${names.join(", ")}`
})
const failed = () => tools().some((part) => part.state.status === "error")
const toggle = disclosure.toggle
const children = (mode: "normal" | "thought" | "tool") => (
<Children {...props} nodes={props.node.children} mode={mode} />
@@ -142,7 +143,7 @@ function GroupContent(props: GroupProps) {
<Show when={grouped()} fallback={children("normal")}>
<Show when={tools().length > 0}>
<InlineToolRow
icon={completed() ? "→" : "✱"}
icon={failed() ? "✗" : completed() ? "→" : "✱"}
color={hover() ? theme.text.base : theme.text.muted}
complete={completed()}
pending={label()}
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { afterAll, expect, test } from "bun:test"
import { onMount, Show } from "solid-js"
import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import type { TuiKeybind } from "../../../src/config/keybind"
import { ClientProvider } from "../../../src/context/client"
@@ -9,13 +9,10 @@ import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { TuiAppProvider } from "../../../src/context/runtime"
import { StorageProvider } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { tmpdir } from "../../fixture/fixture"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -24,12 +21,8 @@ const sessions = {
parent: session("parent", "Parent"),
"child-a": session("child-a", "First", "parent"),
"child-b": session("child-b", "Second", "parent"),
"child-c": session("child-c", "Third", "parent"),
}
const state = await tmpdir()
afterAll(() => state[Symbol.asyncDispose]())
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev"), shell("sh-c", "python3 - <<'PY'\nimport json")]
async function renderComposer(
@@ -85,7 +78,6 @@ async function renderComposer(
data.session.sync("parent"),
data.session.sync("child-a"),
data.session.sync("child-b"),
data.session.sync("child-c"),
data.shell.sync(),
])
.then(() => wait(() => data.session.status("child-a") === "running"))
@@ -94,12 +86,7 @@ async function renderComposer(
return (
<>
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
{/* Mirrors the app, which keys the session route by sessionID and remounts it on navigation. */}
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(sessionID) => (
<Composer sessionID={sessionID} open={true} defaultTab={defaultTab} onClose={() => closed++} />
)}
</Show>
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
</>
)
}
@@ -115,31 +102,27 @@ async function renderComposer(
const app = await testRender(
() => (
<TestTuiContexts directory={directory} paths={{ state: state.path }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</RouteProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
<AppExit />
</Keymap.Provider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
<TestTuiContexts directory={directory}>
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</RouteProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
<AppExit />
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 100, height: 20, kittyKeyboard: true },
@@ -182,33 +165,6 @@ test("disabled subagent bindings have no component fallbacks", async () => {
}
})
test("the inactive subagent filter only changes with its toggle", async () => {
const composer = await renderComposer("subagents", {})
try {
expect(composer.app.captureCharFrame()).toContain("First")
expect(composer.app.captureCharFrame()).not.toContain("Third")
composer.app.mockInput.pressKey("a", { ctrl: true })
await composer.app.renderOnce()
expect(composer.app.captureCharFrame()).toContain("Third")
expect(composer.app.captureCharFrame()).not.toContain("First")
composer.app.mockInput.pressEnter()
expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-c" })
await composer.app.renderOnce()
expect(composer.app.captureCharFrame()).toContain("Third")
expect(composer.app.captureCharFrame()).toContain("show active")
expect(composer.app.captureCharFrame()).not.toContain("First")
composer.app.mockInput.pressKey("a", { ctrl: true })
await composer.app.renderOnce()
expect(composer.app.captureCharFrame()).toContain("First")
expect(composer.app.captureCharFrame()).not.toContain("Third")
} finally {
composer.app.renderer.destroy()
}
})
test("disabled shell bindings have no component fallbacks", async () => {
const composer = await renderComposer("shell", {
"composer.shell.up": "none",
+9 -16
View File
@@ -12,8 +12,6 @@ import { DataProvider as DataProviderBase, useData } from "../../../src/context/
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { TuiAppProvider } from "../../../src/context/runtime"
import { StorageProvider } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { DialogProvider } from "../../../src/ui/dialog"
@@ -22,7 +20,7 @@ import { createSessionRows, type SessionRow } from "../../../src/routes/session/
import { groupRefs } from "../../../src/routes/session/grouping/session"
import { unwrap } from "solid-js/store"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -2232,7 +2230,6 @@ test("keeps shell state scoped to location", async () => {
})
}, events)
let data!: ReturnType<typeof useData>
await using state = await tmpdir()
function Probe() {
data = useData()
@@ -2252,18 +2249,14 @@ test("keeps shell state scoped to location", async () => {
}
const app = await testRender(() => (
<TestTuiContexts paths={{ state: state.path }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</StorageProvider>
</TuiAppProvider>
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
app.renderer.start()
@@ -9,8 +9,6 @@ import { ClientProvider } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { Keymap } from "../../src/context/keymap"
import { RouteProvider } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider } from "../../src/context/storage"
import { ThemeProvider } from "../../src/context/theme"
import { Composer } from "../../src/routes/session/composer"
import { DialogProvider } from "../../src/ui/dialog"
@@ -68,27 +66,23 @@ async function setup(width: number, output = "") {
const app = await testRender(
() => (
<TestTuiContexts directory={temporary.path} paths={{ state: temporary.path }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig()}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_fixture" }}>
<ClientProvider api={api}>
<DataProvider directory={temporary.path}>
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<Shells />
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
<ConfigProvider config={createTuiResolvedConfig()}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_fixture" }}>
<ClientProvider api={api}>
<DataProvider directory={temporary.path}>
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<Shells />
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 30, kittyKeyboard: true },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.23",
"version": "2.0.22",
"type": "module",
"license": "MIT",
"repository": {
+2 -3
View File
@@ -8,7 +8,6 @@ import { Glob } from "./glob.js"
import { serviceUse } from "./effect/service-use.js"
import { makeGlobalNode } from "./effect/app-node.js"
import { filesystem } from "./effect/app-node-platform.js"
import { sameDirectory } from "./path.js"
export namespace FSUtil {
export class FileSystemError extends Schema.TaggedError<FileSystemError>()("FileSystemError", {
@@ -179,7 +178,7 @@ export namespace FSUtil {
if (options.mode === "first") return result
}
}
if (options.stop && sameDirectory(options.stop, current)) break
if (options.stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
@@ -199,7 +198,7 @@ export namespace FSUtil {
Effect.orElseSucceed(() => [] as string[]),
)
result.push(...matches)
if (stop && sameDirectory(stop, current)) break
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more