Compare commits

..
Author SHA1 Message Date
opencode 0fd7e28294 release: v2.0.23 2026-10-05 05:24:37 +00:00
92 changed files with 2127 additions and 5561 deletions

No files matched your search

+45 -62
View File
@@ -422,74 +422,57 @@ const AnthropicUsage = Schema.StructWithRest(
)
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// redacted_thinking blocks arrive whole in content_block_start with the
// encrypted payload in `data`; there is no streaming delta sequence.
data: Schema.optional(Schema.String),
input: Schema.optional(Schema.Unknown),
// *_tool_result blocks arrive whole as content_block_start (no streaming
// delta) with the structured payload in `content` and the originating
// server_tool_use id in `tool_use_id`.
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
}),
[JsonObject],
)
const AnthropicStreamBlock = Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// redacted_thinking blocks arrive whole in content_block_start with the
// encrypted payload in `data`; there is no streaming delta sequence.
data: Schema.optional(Schema.String),
input: Schema.optional(Schema.Unknown),
// *_tool_result blocks arrive whole as content_block_start (no streaming
// delta) with the structured payload in `content` and the originating
// server_tool_use id in `tool_use_id`.
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
})
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
partial_json: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
stop_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
[JsonObject],
),
),
}),
[JsonObject],
)
const AnthropicStreamDelta = Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
partial_json: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
stop_details: optionalNull(
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
),
})
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
const AnthropicEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(
Schema.StructWithRest(Schema.Struct({ usage: Schema.optional(AnthropicUsage) }), [JsonObject]),
),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.StructWithRest(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
[JsonObject],
),
),
}),
[JsonObject],
)
const AnthropicEvent = Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
),
})
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
+12 -20
View File
@@ -301,26 +301,18 @@ export const OpenResponsesBody = Schema.Struct({
})
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
export const OpenResponsesUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
[JsonObject],
),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(
Schema.StructWithRest(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) }), [JsonObject]),
),
total_tokens: Schema.optional(Schema.Number),
}),
[JsonObject],
)
export const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
@@ -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, {
+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 -66
View File
@@ -7,7 +7,6 @@ import { Script } from "@opencode/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { discoverPluginRuntimeSpecifiers } from "../src/plugin-runtime"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
@@ -79,63 +78,6 @@ const appAssetsPlugin: BunPlugin = {
}))
},
}
const pluginRuntimeSpecifiers = discoverPluginRuntimeSpecifiers()
const pluginRuntimeLoaderCode = (specifier: string) => {
if (specifier.startsWith("effect/")) {
const subpath = specifier.slice("effect/".length)
const slash = subpath.lastIndexOf("/")
const parent = slash === -1 ? "effect" : `effect/${subpath.slice(0, slash)}`
const member = slash === -1 ? subpath : subpath.slice(slash + 1)
const parentPath = pluginRuntimeSpecifiers.get(parent)
if (parentPath && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(member) && member in require(parentPath)) {
return `() => require(${JSON.stringify(parent)}).${member}`
}
}
return `() => require(${JSON.stringify(specifier)})`
}
const pluginRuntimeSource = `const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
const modules = {
${[...pluginRuntimeSpecifiers.keys()].map((specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier)},`).join("\n")}
}
export function ensurePluginRuntime() {
const state = globalThis
if (state[runtimeModulesKey]) return state[runtimeModulesKey]
state[runtimeModulesKey] = modules
Bun.plugin({
name: "opencode-plugin-runtime",
setup(build) {
for (const [specifier, load] of Object.entries(modules)) {
build.module(specifier, () => ({ exports: load(), loader: "object" }))
}
build.onLoad(
{ filter: /[/\\\\]node_modules[/\\\\](?:effect|@opencode[/\\\\]plugin)[/\\\\].*\\.[cm]?[jt]sx?(?:[?#].*)?$/ },
(args) => {
const match = args.path.replaceAll("\\\\", "/").match(/\\/node_modules\\/((?:@opencode\\/plugin|effect)\\/.+)$/)
const target = match ? match[1] : args.path
throw new Error(
\`Cannot load "\${target}" from plugin node_modules: "\${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.\`,
)
},
)
},
})
return modules
}
`
const pluginRuntimePlugin: BunPlugin = {
name: "opencode-plugin-runtime",
setup(build) {
build.onLoad({ filter: /cli[/\\]src[/\\]plugin-runtime\.ts$/ }, () => ({
contents: pluginRuntimeSource,
loader: "js",
}))
build.onLoad({ filter: /[/\\]internal[/\\]httpApi(?:Scalar|Swagger)\.js$/ }, () => ({
contents:
'export const css = ""; export const javascript = \'document.body.textContent = "Scalar/Swagger UI assets are not bundled in OpenCode"\'',
loader: "js",
}))
},
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
@@ -182,14 +124,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [
appAssetsPlugin,
solidPlugin,
parcelWatcherPlugin,
opencodePtyPlugin,
pluginRuntimePlugin,
simulationGraphPlugin,
],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+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"
-3
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bun
import { ensurePluginRuntime } from "./plugin-runtime"
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
@@ -18,8 +17,6 @@ import { EffectFlock } from "@opencode/util/effect-flock"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
ensurePluginRuntime()
if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
const { askpass } = await import("./ssh-askpass")
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
-105
View File
@@ -1,105 +0,0 @@
import { existsSync, realpathSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>
const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
type GlobalState = typeof globalThis & {
[runtimeModulesKey]?: Readonly<Record<string, RuntimeModuleLoader>>
}
export function discoverPluginRuntimeSpecifiers(): ReadonlyMap<string, string> {
const entries = new Map<string, string>()
for (const pkgName of ["effect", "@opencode/plugin"]) {
const dir = realpathSync(path.dirname(Bun.resolveSync(`${pkgName}/package.json`, import.meta.dir)))
const [subdir, ext] = existsSync(path.join(dir, "dist")) ? ["dist", ".js"] : ["src", ".ts"]
const scanDir = path.join(dir, subdir)
entries.set(pkgName, Bun.resolveSync(pkgName, import.meta.dir))
for (const file of new Bun.Glob(`**/*${ext}`).scanSync({ cwd: scanDir })) {
const normalized = file.replaceAll("\\", "/")
if (normalized.startsWith("internal/") || normalized.includes("/internal/") || normalized.startsWith("source.")) {
continue
}
const base = normalized.slice(0, -ext.length)
if (base === "index") continue
if (!base.endsWith("/index")) {
entries.set(`${pkgName}/${base}`, path.join(scanDir, file))
continue
}
for (const specifier of [`${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`]) {
try {
entries.set(specifier, Bun.resolveSync(specifier, import.meta.dir))
} catch {}
}
}
}
return entries
}
export function ensurePluginRuntime(): Readonly<Record<string, RuntimeModuleLoader>> {
if (typeof Bun === "undefined") return {}
const state = globalThis as GlobalState
if (state[runtimeModulesKey]) return state[runtimeModulesKey]
const modules = Object.fromEntries(
[...discoverPluginRuntimeSpecifiers().entries()].map(([specifier, resolved]) => [specifier, createLoader(resolved)]),
)
state[runtimeModulesKey] = modules
const foreignFilter = createForeignPackageFilter()
Bun.plugin({
name: "opencode-plugin-runtime",
setup(build) {
for (const [specifier, load] of Object.entries(modules)) {
build.module(specifier, () => {
const exports = load()
if (exports instanceof Promise) {
return exports.then((value) => ({ exports: value, loader: "object" as const }))
}
return { exports, loader: "object" }
})
}
build.onLoad({ filter: foreignFilter }, (args) => {
throw formatForeignPackageError(args.path)
})
},
})
return modules
}
function createLoader(resolved: string): RuntimeModuleLoader {
let cached: Record<string, unknown> | undefined
return () => {
if (cached) return cached
try {
cached = require(resolved) as Record<string, unknown>
return cached
} catch {
return import(pathToFileURL(resolved).href).then((mod: Record<string, unknown>) => {
cached = mod
return mod
})
}
}
}
function createForeignPackageFilter() {
const roots = new Set<string>()
for (const pkgName of ["effect", "@opencode/plugin"]) {
const dir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, import.meta.dir))
roots.add(dir)
roots.add(realpathSync(dir))
}
const escaped = [...roots].map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")
return new RegExp(
`^(?!(?:${escaped})[/\\\\]).*[/\\\\]node_modules[/\\\\](?:effect|@opencode[/\\\\]plugin)[/\\\\].*\\.[cm]?[jt]sx?(?:[?#].*)?$`,
)
}
function formatForeignPackageError(filePath: string) {
const match = filePath.replaceAll("\\", "/").match(/\/node_modules\/((?:@opencode\/plugin|effect)\/.+)$/)
const target = match ? match[1] : filePath
return new Error(
`Cannot load "${target}" from plugin node_modules: "${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.`,
)
}
+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)
+138 -20
View File
@@ -1,13 +1,18 @@
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
import { contenderPool, spawnServiceContender } from "../service-contender.js"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import {
contenderFailure,
contenderFinished,
type ServiceContender,
spawnServiceContender,
} from "../service-contender.js"
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
@@ -35,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 }
})
@@ -48,9 +53,12 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
/** Ensure a healthy, compatible local service is running. */
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const timing = ensureTiming(options)
const pool = contenderPool(timing)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
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
@@ -82,13 +90,19 @@ 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)
pool.evict(info.pid)
pool.recruitNow()
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
}
} else timeouts = undefined
if (service !== undefined) {
pool.serviceAnswered()
spawnDelay = timing.spawnDelay
const versionMatches = matchesVersion(service.version, options)
const compatible = service.compatible && versionMatches
if (!service.compatible && versionMatches)
@@ -111,15 +125,30 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
pool.evict(service.info.pid)
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 failed = pool.reap()
if (failed !== undefined) return yield* Effect.fail(failed)
if (pool.shouldRecruit(info !== undefined)) {
const finished = [...contenders].filter(contenderFinished)
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) {
yield* announce("missing")
pool.add(yield* spawnContender)
contenders.add(yield* spawnContender)
lastSpawn = Date.now()
}
return Option.none<LocalService>()
}).pipe(
@@ -129,10 +158,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
// like the Promise variant rather than by attempt count.
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
}),
Effect.ensuring(Effect.sync(() => pool.releaseAll())),
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
)
if (Option.isNone(found))
return yield* Effect.fail(pool.failure() ?? new Error("Timed out waiting for the background service to start"))
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
return found.value.endpoint
})
@@ -148,6 +177,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),
@@ -158,6 +198,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) {
@@ -167,10 +214,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
@@ -189,6 +303,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
+127 -15
View File
@@ -1,13 +1,18 @@
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 { contenderPool, spawnServiceContender } from "../service-contender.js"
import {
contenderFailure,
contenderFinished,
type ServiceContender,
spawnServiceContender,
} from "../service-contender.js"
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.
//
@@ -28,9 +33,12 @@ export async function discover(options: DiscoverOptions = {}) {
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const timing = ensureTiming(options)
const deadline = Date.now() + timing.promiseTimeout
const pool = contenderPool(timing)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
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
@@ -49,8 +57,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
try {
while (true) {
if (Date.now() >= deadline)
throw pool.failure() ?? new Error("Timed out waiting for the background service to start")
if (Date.now() >= deadline) throw failure ?? 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 = {
@@ -62,14 +69,20 @@ 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)
pool.evict(registration.info.pid)
pool.recruitNow()
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
}
} else timeouts = undefined
if (registration.service !== undefined) {
pool.serviceAnswered()
spawnDelay = timing.spawnDelay
const service = registration.service
const versionMatches = matchesVersion(service.version, options)
const compatible = service.compatible && versionMatches
@@ -90,20 +103,36 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
pool.evict(service.info.pid)
for (const item of contenders) {
if (item.child.pid === service.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
}
} else {
const failed = pool.reap()
if (failed !== undefined) throw failed
if (pool.shouldRecruit(registration.info !== undefined)) {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished)
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) {
announce("missing")
pool.add(await spawnContender())
contenders.add(await spawnContender())
lastSpawn = Date.now()
}
}
await delay(timing.pollInterval)
}
} finally {
pool.releaseAll()
contenders.forEach((contender) => contender.release())
}
}
@@ -119,6 +148,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
@@ -129,6 +170,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 }
@@ -158,6 +266,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
+1 -65
View File
@@ -1,5 +1,4 @@
import { spawn, type ChildProcess } from "node:child_process"
import type { EnsureTiming } from "./service-timing.js"
export type ServiceContender = {
readonly child: ChildProcess
@@ -54,70 +53,7 @@ export function spawnServiceContender(
}
}
/**
* The startup attempts of one `ensure()` call. It keeps at most two attempts alive, remembers
* the first startup failure, and backs off when attempts exit cleanly because another one won.
*/
export function contenderPool(timing: EnsureTiming) {
const contenders = new Set<ServiceContender>()
let failure: Error | undefined
let spawnDelay = timing.spawnDelay
let lastSpawn = 0
return {
/** The first startup failure seen since the last eviction. */
failure: () => failure,
/** A registered service answered, so the next attempt waits the base delay again. */
serviceAnswered() {
spawnDelay = timing.spawnDelay
},
/**
* The owner `pid` was replaced. Drop its attempt and any finished ones, forget their
* failure, and restart the spawn clock.
*/
evict(pid: number) {
for (const item of contenders) {
if (item.child.pid === pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
},
/** Let the next attempt start without waiting a spawn delay. */
recruitNow() {
lastSpawn = Date.now() - spawnDelay
},
/** Collect finished attempts. Returns the startup failure once no attempt is left alive. */
reap() {
const finished = [...contenders].filter(contenderFinished)
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))
return contenders.size === 0 ? failure : undefined
},
/**
* Whether to start another attempt now. A registration that has not answered yet gets one
* spawn delay before an attempt competes with it.
*/
shouldRecruit(registered: boolean) {
if (lastSpawn === 0 && registered) lastSpawn = Date.now()
// 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.
return failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay
},
add(contender: ServiceContender) {
contenders.add(contender)
lastSpawn = Date.now()
},
releaseAll() {
contenders.forEach((contender) => contender.release())
},
}
}
function contenderFailure(contender: ServiceContender) {
export function contenderFailure(contender: ServiceContender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
-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,126 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import {
contenderFinished,
contenderPool,
type ServiceContender,
spawnServiceContender,
} from "../src/service-contender"
import { defaultEnsureTiming } from "../src/service-timing"
const spawned: ServiceContender[] = []
afterEach(() => {
spawned.splice(0).forEach((contender) => {
contender.release()
contender.child.kill("SIGKILL")
})
})
test("remembers the first startup failure and reports it once no attempt is left", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 0 })
const survivor = running()
const failed = failing("first failure")
pool.add(survivor)
pool.add(failed)
await finished(failed)
expect(pool.reap()).toBeUndefined()
expect(pool.failure()?.message).toContain("first failure")
expect(pool.shouldRecruit(false)).toBe(false)
survivor.child.kill("SIGKILL")
await finished(survivor)
expect(pool.reap()?.message).toContain("first failure")
})
test("evicting a replaced owner forgets its failure and restarts the spawn clock", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 60_000 })
const owner = running()
const failed = failing("old failure")
pool.add(owner)
pool.add(failed)
await finished(failed)
expect(pool.reap()).toBeUndefined()
expect(pool.failure()).toBeDefined()
pool.evict(owner.child.pid!)
expect(pool.failure()).toBeUndefined()
expect(pool.reap()).toBeUndefined()
expect(pool.shouldRecruit(false)).toBe(true)
expect(owner.child.exitCode).toBeNull()
})
test("an unanswered registration gets one spawn delay before an attempt competes", () => {
const timing = { ...defaultEnsureTiming, spawnDelay: 60_000 }
expect(contenderPool(timing).shouldRecruit(false)).toBe(true)
const registered = contenderPool(timing)
expect(registered.shouldRecruit(true)).toBe(false)
registered.recruitNow()
expect(registered.shouldRecruit(true)).toBe(true)
})
test("keeps at most two attempts alive", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 0 })
const exiting = clean()
pool.add(running())
pool.add(exiting)
expect(pool.shouldRecruit(false)).toBe(false)
await finished(exiting)
expect(pool.reap()).toBeUndefined()
expect(pool.shouldRecruit(false)).toBe(true)
})
test("clean exits back off up to the maximum spawn delay", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 200, maxSpawnDelay: 300 })
const exiting = clean()
pool.add(exiting)
const start = Date.now()
await finished(exiting)
expect(pool.reap()).toBeUndefined()
// The base delay (200 ms) has passed, but the backed-off delay (300 ms) has not, unless a
// loaded machine overslept past it.
await Bun.sleep(Math.max(0, 250 - (Date.now() - start)))
if (Date.now() - start < 290) expect(pool.shouldRecruit(false)).toBe(false)
// Doubling would give 400 ms; the cap allows an attempt at 300 ms.
await Bun.sleep(Math.max(0, 320 - (Date.now() - start)))
expect(pool.shouldRecruit(false)).toBe(true)
})
test("an answering service resets the backoff to the base spawn delay", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 200, maxSpawnDelay: 1_000 })
const exiting = clean()
pool.add(exiting)
const start = Date.now()
await finished(exiting)
expect(pool.reap()).toBeUndefined()
pool.serviceAnswered()
// Backed off, the next attempt would wait 400 ms; after the reset it waits the base 200 ms.
await Bun.sleep(Math.max(0, 250 - (Date.now() - start)))
expect(pool.shouldRecruit(false)).toBe(true)
})
function spawn(code: string) {
const contender = spawnServiceContender(process.execPath, ["-e", code])
spawned.push(contender)
return contender
}
function running() {
return spawn("setTimeout(() => {}, 60_000)")
}
function failing(message: string) {
return spawn(`console.error(${JSON.stringify(message)}); process.exit(1)`)
}
function clean() {
return spawn("process.exit(0)")
}
async function finished(contender: ServiceContender) {
while (!contenderFinished(contender)) await Bun.sleep(5)
}
+4 -11
View File
@@ -14,12 +14,7 @@ type Target<ID extends string> = Overlays & {
variants?: (Overlays & { id: ID })[]
}
type Context = {
readonly providerID: string
readonly canonical?: string
readonly modelID?: string
readonly shape?: "responses" | "completions"
}
type Context = { readonly providerID: string; readonly canonical?: string; readonly modelID?: string }
export function rewrite<ID extends string>(
target: Target<ID>,
@@ -131,13 +126,11 @@ export function native(npm: string, context: Context & { readonly settings?: Pro
const host = HOSTS[context.providerID]?.[npm]
if (host) return host
if (npm === "@ai-sdk/amazon-bedrock/mantle") return mantle(context.modelID)
if (npm === "@ai-sdk/azure" && azureChat(context)) return "@opencode/ai/providers/azure/chat"
if (npm === "@ai-sdk/azure" && context.settings?.useCompletionUrls === true)
return "@opencode/ai/providers/azure/chat"
return PACKAGES[npm]
}
const azureChat = (context: Context & { readonly settings?: Provider.Settings }) =>
context.shape === "completions" || context.settings?.useCompletionUrls === true
const mantle = (modelID: string | undefined) => {
if (modelID === undefined) return "@opencode/ai/providers/amazon-bedrock/mantle"
return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}`
@@ -148,7 +141,7 @@ function resolve(specifier: string, context: Context & { readonly settings?: Pro
if (Provider.isAISDK(specifier) || npm in PACKAGES || npm in (HOSTS[context.providerID] ?? {}))
return native(npm, context)
if (npm === "@opencode/ai/providers/amazon-bedrock/mantle") return mantle(context.modelID)
if (npm === "@opencode/ai/providers/azure/responses" && azureChat(context))
if (npm === "@opencode/ai/providers/azure/responses" && context.settings?.useCompletionUrls === true)
return "@opencode/ai/providers/azure/chat"
return NATIVE.has(npm) ? npm : undefined
}
@@ -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)]
}
+24 -50
View File
@@ -17,7 +17,6 @@ import {
} from "@modelcontextprotocol/client"
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
import { Cause, Deferred, Effect } from "effect"
import type { ServerResponse } from "node:http"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { Credential } from "../credential.js"
import { OauthCallbackPage } from "../oauth/page.js"
@@ -349,7 +348,7 @@ export const authorize = (input: {
const client = clientFromCredential(previous)
if (client) yield* Effect.promise(() => store.saveClientInformation(client))
}
const code = yield* Deferred.make<{ code: string; iss: string | undefined; response: ServerResponse }, Error>()
const code = yield* Deferred.make<{ code: string; iss: string | undefined }, Error>()
const redirect = oauth?.redirect_uri ? new URL(oauth.redirect_uri) : undefined
const redirectPath = redirect?.pathname ?? "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
@@ -364,7 +363,7 @@ export const authorize = (input: {
}
const fail = (reason: string, failure: string) => {
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
Effect.runSync(Deferred.fail(code, new Error(reason)))
Effect.runFork(Deferred.fail(code, new Error(reason)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(reason, { provider: input.name }))
@@ -374,11 +373,8 @@ export const authorize = (input: {
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
const value = url.searchParams.get("code")
if (!value) return fail("Missing authorization code", "missing_code")
// The page waits for the token exchange so the browser never reports success for a rejected code.
if (
!Effect.runSync(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined, response }))
)
response.writeHead(409).end("OAuth callback already received")
Effect.runFork(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined }))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
})
// callback_port, else the port pinned by redirect_uri, else ephemeral; a mismatch strands the browser.
@@ -474,52 +470,30 @@ export const authorize = (input: {
if (!authorizationUrl)
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
const completed = yield* Deferred.make<Credential.OAuth, Error>()
yield* Deferred.await(code).pipe(
Effect.flatMap((value) => {
const respond = (error?: string) =>
Effect.sync(() =>
value.response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: input.name })
: OauthCallbackPage.success({ provider: input.name }),
),
)
return Effect.tryPromise({
try: () =>
auth(oauthProvider, {
serverUrl: input.config.url,
authorizationCode: value.code,
iss: value.iss,
scope: oauth?.scope ?? challenge.scope,
fetchFn,
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.flatMap(() => finalize),
Effect.tap(() => respond()),
Effect.tapError((error) => respond(error.message)),
Effect.onInterrupt(() => Effect.sync(() => value.response.destroy())),
)
}),
Effect.onErrorIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) =>
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
),
Effect.annotateLogs(fields),
Effect.exit,
Effect.flatMap((exit) => Deferred.done(completed, exit)),
Effect.forkScoped({ startImmediately: true }),
)
return {
url: authorizationUrl.toString(),
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
mode: "auto" as const,
callback: Deferred.await(completed),
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
Effect.tryPromise({
try: () =>
auth(oauthProvider, {
serverUrl: input.config.url,
authorizationCode: value.code,
iss: value.iss,
scope: oauth?.scope ?? challenge.scope,
fetchFn,
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
),
Effect.flatMap(() => finalize),
Effect.onError((cause) =>
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
),
Effect.annotateLogs(fields),
),
}
}).pipe(
Effect.onError((cause) =>
+1 -3
View File
@@ -408,9 +408,7 @@ export const layer = Layer.effect(
Effect.flatMap((model) =>
model && hasPackage(model)
? Effect.succeed(model)
: Effect.map(models.available(), (models) =>
models.find((model) => hasPackage(model) && Model.supportsText(model)),
),
: Effect.map(models.available(), (models) => models.find(hasPackage)),
),
)
if (!selected) return undefined
+1 -8
View File
@@ -261,7 +261,7 @@ const layer = Layer.effect(
const value = yield* read()
const requested = value.data.defaultModel
const model = requested && value.byProvider.get(requested.providerID)?.get(requested.modelID)
return model?.enabled ? model : value.available.find(supportsText)
return model?.enabled ? model : value.available[0]
}),
small: Effect.fn("Model.small")(function* (providerID) {
const value = yield* read()
@@ -282,13 +282,6 @@ const layer = Layer.effect(
export const node = makeLocationNode({ service: Service, layer, deps: [Provider.node, Bus.node, Location.node] })
export function supportsText(model: Pick<Info, "capabilities">) {
return (
(model.capabilities.input.length === 0 || model.capabilities.input.some((item) => item.startsWith("text"))) &&
(model.capabilities.output.length === 0 || model.capabilities.output.some((item) => item.startsWith("text")))
)
}
export function compatibility(input: unknown): Compatibility | undefined {
if (typeof input === "string") return { reasoningField: input }
if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined
+4 -15
View File
@@ -60,11 +60,7 @@ type SourceModel = {
>
}
readonly status?: CatalogModelStatus
readonly provider?: {
readonly npm?: string
readonly api?: string
readonly shape?: "responses" | "completions"
}
readonly provider?: { readonly npm?: string; readonly api?: string }
}
type SourceProvider = {
@@ -84,13 +80,7 @@ export type Snapshot = {
function nativePackage(provider: SourceProvider, model?: SourceModel) {
const npm = model?.provider?.npm ?? provider.npm
return (
AISDKNative.native(npm, {
providerID: provider.id,
modelID: model?.id,
shape: model?.provider?.shape,
}) ?? Provider.aisdk(npm)
)
return AISDKNative.native(npm, { providerID: provider.id, modelID: model?.id }) ?? Provider.aisdk(npm)
}
function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
@@ -213,12 +203,11 @@ function modelInfo(
} = {},
): Model.Info {
const providerID = Provider.ID.make(provider.id)
const resolved = nativePackage(provider, model)
const pkg = model.provider?.npm || resolved !== nativePackage(provider) ? resolved : undefined
const pkg = model.provider?.npm ? nativePackage(provider, model) : undefined
// Per model, so it never merges into a model that overrides to a different package.
const settings = {
...(model.provider?.api ? { baseURL: model.provider.api } : {}),
...(resolved === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}),
...(nativePackage(provider, model) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}),
}
return {
id,
+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
+10 -5
View File
@@ -7,13 +7,19 @@ 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:
- Your role is EXCLUSIVELY to search and analyze
- Parallelize independent tool calls for searches and reads whenever possible
- 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
- Adapt your search approach based on the thoroughness level specified by the caller
- Return file paths as absolute paths in your final response
- 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
- 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
Complete the user's search request efficiently and report your findings clearly.`
@@ -109,7 +115,6 @@ 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" },
-2
View File
@@ -3,7 +3,6 @@ import { Integration } from "@opencode/schema/integration"
import { Provider } from "@opencode/schema/provider"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Model } from "../model.js"
import { ModelsDev } from "../models-dev.js"
// These catalog entries require inference profiles on Bedrock Runtime.
@@ -101,7 +100,6 @@ function snapshots(data: readonly ModelsDev.Snapshot[]) {
models: provider.models.filter(
(model) =>
model.status !== "deprecated" &&
Model.supportsText(model) &&
!(
provider.info.id === Provider.ID.amazonBedrock &&
BEDROCK_PROFILE_ONLY_IDS.includes(model.modelID ?? model.id)
@@ -470,8 +470,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
})
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
// Scoped so responses whose body is never read (404, errors) are released here instead of by a GC-time abort.
return HttpClient.withScope(http)
return http
.execute(
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
@@ -499,7 +498,6 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
)
}),
),
Effect.scoped,
)
}
@@ -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
)
}
+10 -19
View File
@@ -210,10 +210,7 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
})
const powershell = ShellSelect.ps(shell)
const name = ShellSelect.name(shell)
const result = powershell
? ShellScan.scanPowerShell(command)
: ShellScan.scan(command, name === "bash" || name === "zsh" ? name : "posix")
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
if (result.kind === "opaque")
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
@@ -228,21 +225,15 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
if (CWD.has(name)) {
output.directories.push(
...directoryArgs(
powershell
? words.flatMap((text): Part[] => {
const parameter = /^(-(?:literalpath|path)):(.*)$/i.exec(text)
if (parameter)
return [
{ type: "command_parameter", text: parameter[1] },
{ type: "word", text: parameter[2] },
]
return [{ type: text.startsWith("-") ? "command_parameter" : "word", text }]
})
: item.rawWords.map((text, index) => ({
type: "word",
// Only literal quoting resolves to a static directory.
text: text.startsWith("$'") || (!/[$`~\\]/.test(text) && /['"]/.test(text)) ? item.words[index] : text,
})),
words.flatMap((text): Part[] => {
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
if (parameter)
return [
{ type: "command_parameter", text: parameter[1] },
{ type: "word", text: parameter[2] },
]
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
}),
powershell,
cwd,
shell,
File diff suppressed because it is too large. Load diff
-1
View File
@@ -575,7 +575,6 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/zai-coding-plan/responses": openaiResponses,
"@opencode/ai/providers/openai-compatible": openaiCompatible,
"@opencode/ai/providers/azure/chat": openaiChat,
"@opencode/ai/providers/google-vertex/chat": openaiChat,
"@opencode/ai/providers/alibaba/chat": alibabaChat,
"@opencode/ai/providers/baseten": basetenChat,
-2
View File
@@ -203,8 +203,6 @@ 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")
-3
View File
@@ -224,9 +224,6 @@ describe("AISDKNative", () => {
const chat = map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")
expect(chat?.package).toBe("@opencode/ai/providers/azure/chat")
expect(chat?.settings).not.toHaveProperty("useCompletionUrls")
expect(AISDKNative.native("@ai-sdk/azure", { providerID: "azure", shape: "completions" })).toBe(
"@opencode/ai/providers/azure/chat",
)
})
test("maps Bedrock provider and request options", () => {
-10
View File
@@ -553,16 +553,6 @@ describe("Provider and Model", () => {
editor.models.update(providerID, Model.ID.make("new"), (model) => {
model.time.released = 2000
})
editor.models.update(providerID, Model.ID.make("newest-video"), (model) => {
model.capabilities.input = ["text", "image"]
model.capabilities.output = ["video"]
model.time.released = 3000
})
editor.models.update(providerID, Model.ID.make("newest-stt"), (model) => {
model.capabilities.input = ["audio"]
model.capabilities.output = ["text"]
model.time.released = 4000
})
})
expect((yield* models.default())?.id).toMatch("new")
@@ -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,
-35
View File
@@ -145,7 +145,6 @@ describe("MCP OAuth", () => {
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", state)
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(200)
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(409)
return yield* authorization.callback
}),
),
@@ -160,40 +159,6 @@ describe("MCP OAuth", () => {
expect(tokenRequests[0]?.get("resource")).toBe(server.url.href)
})
test("reports a rejected token exchange on the callback page", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
if (request.method !== "POST" || new URL(request.url).pathname !== "/token")
return new Response(null, { status: 404 })
return Response.json(
{ error: "invalid_client", error_description: "bad_client_secret" },
{ status: 400 },
)
},
})
const result = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { authorization, url: authorizationUrl } = yield* start(server, { client_id: "client" })
const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri") ?? "")
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", authorizationUrl.searchParams.get("state") ?? "")
const response = yield* Effect.promise(() => fetch(redirect))
const page = yield* Effect.promise(() => response.text())
const exit = yield* Effect.exit(authorization.callback)
return { status: response.status, page, exit }
}),
),
).finally(() => server.stop(true))
expect(result.status).toBe(400)
expect(result.page).toContain("Authorization failed")
expect(result.page).toContain("bad_client_secret")
expect(result.exit._tag).toBe("Failure")
})
test("refreshes tokens loaded from a persisted credential", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
-22
View File
@@ -255,23 +255,6 @@ describe("ModelsDev Service", () => {
api: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
models: {},
},
azure: {
id: "azure",
name: "Azure",
env: ["AZURE_RESOURCE_NAME", "AZURE_API_KEY"],
npm: "@ai-sdk/azure",
models: {
"gpt-5": {
...fixture.acme.models["acme-1"],
id: "gpt-5",
},
"custom-chat": {
...fixture.acme.models["acme-1"],
id: "custom-chat",
provider: { shape: "completions" },
},
},
},
})
const state = yield* Ref.make(initialState)
const result = yield* provided(
@@ -283,11 +266,6 @@ describe("ModelsDev Service", () => {
expect(result[0]?.models[0]?.package).toBe("@opencode/ai/providers/openai")
expect(result[1]?.info.package).toBe("@opencode/ai/providers/cloudflare-workers-ai")
expect(result[1]?.info.settings).toBeUndefined()
expect(result[2]?.info.package).toBe("@opencode/ai/providers/azure/responses")
expect(result[2]?.models.find((model) => model.id === "gpt-5")?.package).toBeUndefined()
expect(result[2]?.models.find((model) => model.id === "custom-chat")?.package).toBe(
"@opencode/ai/providers/azure/chat",
)
}),
)
+2 -21
View File
@@ -624,7 +624,7 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("omits deprecated and non-text model definitions", () =>
it.effect("omits deprecated model definitions", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const providers = yield* Provider.Service
@@ -632,13 +632,11 @@ describe("ModelsDevPlugin", () => {
const providerID = Provider.ID.make("acme")
const activeID = Model.ID.make("current")
const deprecatedID = Model.ID.make("legacy")
const videoID = Model.ID.make("video-gen")
const sttID = Model.ID.make("transcribe")
const model = {
modelID: activeID,
providerID,
name: "Current",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
@@ -664,21 +662,6 @@ describe("ModelsDevPlugin", () => {
name: "Legacy",
status: "deprecated" as const,
},
{
id: videoID,
...model,
modelID: videoID,
name: "Video Gen",
capabilities: { tools: false, input: ["text", "image"], output: ["video"] },
limit: { context: 1_024, output: 0 },
},
{
id: sttID,
...model,
modelID: sttID,
name: "Transcribe",
capabilities: { tools: false, input: ["audio"], output: ["text"] },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
@@ -701,8 +684,6 @@ describe("ModelsDevPlugin", () => {
yield* activate(providers)
expect(yield* modelState.get(providerID, activeID)).toBeDefined()
expect(yield* modelState.get(providerID, deprecatedID)).toBeUndefined()
expect(yield* modelState.get(providerID, videoID)).toBeUndefined()
expect(yield* modelState.get(providerID, sttID)).toBeUndefined()
}),
)
+2 -286
View File
@@ -1,30 +1,17 @@
import { expect } from "bun:test"
import { cp } from "node:fs/promises"
import path from "node:path"
import { Brand, Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
import { Agent } from "@opencode/schema/agent"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Plugin } from "@opencode/core/plugin"
import { PluginModule } from "@opencode/core/plugin/module"
import { Rpc } from "@opencode/core/rpc"
import { Tool } from "@opencode/core/tool"
import { execute } from "@opencode/core/tool/runtime"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
import { ensurePluginRuntime } from "../../../cli/src/plugin-runtime"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
ensurePluginRuntime()
const it = testEffect(
Layer.mergeAll(
PluginTestLayer,
Layer.merge(
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
),
@@ -103,274 +90,3 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
yield* Effect.promise(() => Bun.sleep(50))
}),
)
it.live("loads plugins and their transitive dependencies against the host's Effect and @opencode/plugin instances", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "plugin")
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
yield* Effect.promise(async () => {
await cp(path.join(hostEffectDir, "dist"), path.join(pluginEffectDir, "dist"), {
recursive: true,
filter: (src) => !src.endsWith(".d.ts") && !src.endsWith(".map") && !/httpApi(?:Scalar|Swagger)\.js$/.test(src),
})
const pkg = { ...(await Bun.file(path.join(hostEffectDir, "package.json")).json()), version: "4.0.0-rc.111" }
await Bun.write(path.join(pluginEffectDir, "package.json"), JSON.stringify(pkg))
// Sabotage the plugin's own Effect copy with the version-skew failure modes so loading it would crash:
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
await Bun.write(
internalEffectPath,
(await Bun.file(internalEffectPath).text())
.replace(
"const logLevel = level ?? fiber.currentLogLevel;\n if (isLogLevelGreaterThan(fiber.minimumLogLevel, logLevel)) {",
"const logLevel = level ?? fiber.cache?.logLevel;\n if (isLogLevelGreaterThan(fiber.cache?.minimumLogLevel, logLevel)) {",
)
.replace(
"const runPromiseExit = runPromiseExitWith(context);",
"if (true) return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });\n const runPromiseExit = runPromiseExitWith(context);",
),
)
const depDir = path.join(pluginDir, "node_modules/transitive-dep")
await Bun.write(
path.join(depDir, "package.json"),
'{"name":"transitive-dep","type":"module","exports":{".":"./index.js"}}',
)
await Bun.write(
path.join(depDir, "index.js"),
`import { Effect, Schema } from "effect"
import { some } from "effect/Option"
export const depToolInput = Schema.Struct({
mode: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed("from-dep"))),
count: Schema.Int,
code: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
})
export const depCaptured = { Effect, Schema, some }`,
)
const foreignPluginPkgDir = path.join(pluginDir, "node_modules/@opencode/plugin")
await Bun.write(
path.join(foreignPluginPkgDir, "package.json"),
'{"name":"@opencode/plugin","type":"module","exports":{"./effect":"./effect.js","./rpc":"./rpc.js"}}',
)
await Bun.write(path.join(foreignPluginPkgDir, "effect.js"), "export const Plugin = { define: (p) => p }")
await Bun.write(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
await Bun.write(
path.join(pluginDir, "index.ts"),
`import { Plugin } from "@opencode/plugin/effect"
import { Rpc } from "@opencode/plugin/rpc"
import { Effect, Schema } from "effect"
import { some } from "effect/Option"
import { nominal } from "effect/Brand"
import { depCaptured, depToolInput } from "transitive-dep"
export const captured = {
plugin: { Effect, Schema, some, nominal },
dep: depCaptured,
pluginCount: -1,
}
const Contract = Rpc.define({
id: "host-effect-rpc",
methods: {
check: {
input: Schema.Struct({
count: Schema.Int.pipe(Schema.withDecodingDefault(Effect.succeed(5))),
tag: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
}),
output: Schema.Struct({ value: Schema.String }),
},
},
events: {},
})
export default Plugin.define({
id: "host-effect-fixture",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.log("setup log from plugin")
const listed = yield* Effect.promise(() =>
Effect.runPromise(ctx.plugin.list().pipe(Effect.orDie)),
)
captured.pluginCount = listed.data.length
yield* ctx.tool.transform((editor) => {
editor.add({
name: "check_tool",
description: "Tool with decoding default from transitive dependency and Int/Trim/isPattern checks",
input: depToolInput,
output: Schema.Struct({ formatted: Schema.String }),
execute: ({ mode, count, code }) =>
Effect.log("executing check_tool").pipe(
Effect.as({
output: { formatted: \`\${mode}:\${code}:\${count}\` },
content: \`\${mode}:\${code}:\${count}\`,
}),
),
})
})
yield* ctx.rpc.register(Contract, {
check: ({ count, tag }) =>
Effect.log("executing rpc check").pipe(
Effect.as({ value: \`\${tag}#\${count}\` }),
),
}).pipe(Effect.orDie)
}),
})`,
)
})
const modules = yield* PluginModule.make()
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const rpc = yield* Rpc.Service
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
yield* plugins.activate([definition])
yield* plugins.awaitActivation
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
expect(imported.captured.plugin.Effect).toBe(Effect)
expect(imported.captured.plugin.Schema).toBe(Schema)
expect(imported.captured.plugin.some).toBe(Option.some)
expect(imported.captured.plugin.nominal).toBe(Brand.nominal)
expect(imported.captured.dep.Effect).toBe(Effect)
expect(imported.captured.dep.Schema).toBe(Schema)
expect(imported.captured.dep.some).toBe(Option.some)
expect(imported.captured.pluginCount).toBe(0)
const checkTool = (yield* tools.list()).find((tool) => tool.id === "check_tool")
expect(checkTool).toBeDefined()
if (!checkTool) return
const context = {
sessionID: Session.ID.make("ses_host_effect"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_host_effect"),
id: Tool.CallID.make("call_host_effect"),
progress: () => Effect.void,
}
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
output: { formatted: "from-dep:v42:3" },
content: [{ type: "text", text: "from-dep:v42:3" }],
})
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
}),
)
it.live("fails loudly at load when a plugin imports an unprovided effect/* subpath from its own node_modules, while allowing effect/package.json", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const okDir = path.join(directory.path, "pkg-json-plugin")
const badDir = path.join(directory.path, "removed-subpath-plugin")
yield* Effect.promise(async () => {
await Bun.write(
path.join(okDir, "node_modules/effect/package.json"),
'{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{"./package.json":"./package.json"}}',
)
await Bun.write(
path.join(okDir, "index.ts"),
`import pkg from "effect/package.json" with { type: "json" }
export const effectPkgName = pkg.name
export default { id: "pkg-json-plugin", async setup() {} }`,
)
await Bun.write(
path.join(badDir, "node_modules/effect/package.json"),
'{"name":"effect","type":"module","exports":{"./RemovedLegacySubpath":"./RemovedLegacySubpath.js"}}',
)
await Bun.write(path.join(badDir, "node_modules/effect/RemovedLegacySubpath.js"), "export const legacy = true")
await Bun.write(
path.join(badDir, "index.ts"),
`import { legacy } from "effect/RemovedLegacySubpath"
export default { id: "removed-subpath", async setup() { void legacy } }`,
)
})
const modules = yield* PluginModule.make()
expect(yield* modules.load({ type: "add", target: okDir, options: {} })).toMatchObject({ id: "pkg-json-plugin" })
const exit = yield* modules.load({ type: "add", target: badDir, options: {} }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(String(Cause.squash(exit.cause))).toContain("effect/RemovedLegacySubpath.js")
}
}),
)
it.live("redirects plugin dependencies with a nested Effect 3 installation to the host's Effect instance", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "v3-dep-plugin")
const v3DepDir = path.join(pluginDir, "node_modules/v3-dep")
const nestedEffect3Dir = path.join(v3DepDir, "node_modules/effect")
yield* Effect.promise(async () => {
await Bun.write(
path.join(nestedEffect3Dir, "package.json"),
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./ReadonlyArray":"./ReadonlyArray.js"}}',
)
await Bun.write(
path.join(nestedEffect3Dir, "index.js"),
"export const Effect = { major: 3 }; export const Schema = { major: 3 }",
)
await Bun.write(path.join(nestedEffect3Dir, "Option.js"), "export const some = () => ({ major: 3 })")
await Bun.write(path.join(nestedEffect3Dir, "ReadonlyArray.js"), "export const fromIterable = () => []")
await Bun.write(
path.join(v3DepDir, "package.json"),
'{"name":"v3-dep","type":"module","exports":{".":"./index.js","./v3-only":"./v3-only.js"}}',
)
await Bun.write(
path.join(v3DepDir, "index.js"),
`import { Effect, Schema } from "effect"
import { some } from "effect/Option"
export const v3DepCaptured = { Effect, Schema, some }`,
)
await Bun.write(
path.join(v3DepDir, "v3-only.js"),
`import { fromIterable } from "effect/ReadonlyArray"
export { fromIterable }`,
)
await Bun.write(
path.join(pluginDir, "index.ts"),
`import { v3DepCaptured } from "v3-dep"
export { v3DepCaptured }
export default { id: "v3-dep-plugin", async setup() {} }`,
)
})
const modules = yield* PluginModule.make()
expect(yield* modules.load({ type: "add", target: pluginDir, options: {} })).toMatchObject({ id: "v3-dep-plugin" })
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
expect(imported.v3DepCaptured.Effect).toBe(Effect)
expect(imported.v3DepCaptured.Schema).toBe(Schema)
expect(imported.v3DepCaptured.some).toBe(Option.some)
const v3OnlyExit = yield* Effect.promise(() =>
import(path.join(v3DepDir, "v3-only.js")).then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error: String(error) }),
),
)
expect(v3OnlyExit.ok).toBe(false)
if (!v3OnlyExit.ok) {
expect(v3OnlyExit.error).toContain("effect/ReadonlyArray.js")
}
}),
)
@@ -1072,30 +1072,6 @@ describe("OpencodePlugin", () => {
),
)
it.effect("releases unread Console config responses when the fetch completes", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const signals: AbortSignal[] = []
const state = { status: 404 }
const http = HttpClient.make((request, _url, signal) => {
signals.push(signal)
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unavailable", state)))
})
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: "https://console.test" } }),
})
yield* addPlugin().pipe(Effect.provideService(HttpClient.HttpClient, http))
yield* drain
state.status = 503
yield* TestClock.adjust("1 minute")
yield* drain
// An unreleased response is only aborted when garbage collected, which fails outside a request on workerd.
expect(signals.map((signal) => signal.aborted)).toEqual([true, true])
}),
)
it.effect("reports a rejected Console refresh token as signed out without removing the credential", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
@@ -42,21 +42,6 @@ describe("ShellScan adversarial corpus", () => {
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
// Only shell sinks evaluate subscripts; ordinary arguments, heredoc bodies, and bound data without a sink are safe.
test.each([
"bun -e 'f(`a[${x}]`)'",
"rg 'a[$(x)]'",
"cat <<'EOF'\na[$(x)]\nEOF",
"echo 'a[$(x)]'",
'BODY="- [x] Fix \\`scan.ts\\`"; gh pr create --title "fix" --body "$BODY"',
"MSG='[feat] Fix `foo`'; git commit -m \"$MSG\"",
"PATTERN='a[${b}]'; rg \"$PATTERN\"",
"export REGEX='[0-9]+${foo}'",
"cat <<< 'const a = arr[0]; const b = `${a}`'",
])("scans subscript-shaped text outside shell sinks: %s", (input) => {
expect(ShellScan.scan(input).kind).toBe("scanned")
})
test.each(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
"keeps structurally uncertain Bash input opaque: %s",
(input) => {
@@ -64,19 +49,6 @@ describe("ShellScan adversarial corpus", () => {
},
)
test.each([
["repeated assignment value operators", "x[a]" + "=]".repeat(32_000)],
["unclosed assignment subscripts", "a[\n".repeat(21_000)],
["case patterns with substitutions", "case x in " + "[$(:)".repeat(10_000)],
["nested groups with bracket words", "{ ".repeat(31) + "echo " + "a[] ".repeat(15_000) + "; }".repeat(31)],
["continued heredoc lines", "cat <<E\n" + "x\\\n".repeat(20_000) + "E\n"],
["heredoc backslash runs", "cat <<E\n" + "\\".repeat(60_000) + "x\nE\n"],
])("scans adversarial Bash input in bounded time: %s", (_, input) => {
const start = performance.now()
ShellScan.scan(input)
expect(performance.now() - start).toBeLessThan(500)
})
test.each([
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
["Import-Module ./evil.psm1", ["Import-Module"]],
@@ -60,41 +60,23 @@ const fixtures = [
["! scan_probe", ["scan_probe"]],
["time scan_probe", ["time"]],
["{fd}>/dev/null scan_probe", ["scan_probe"]],
["case $r in a) ls | head;; esac", ["ls", "head"]],
["case $r in a) ls && echo;; esac", ["ls", "echo"]],
["{ find . -exec echo {} \\; ; }", ["find"]],
["{ echo {a,{b,c}}; }", ["echo"]],
["if true; then\\\n echo hi; fi", ["true", "echo"]],
["for ((i=0; i<2; i++)) do echo hi; done", ["echo"]],
['echo "$(case x in @(a)) echo hi;; esac)"', ["echo", "echo"]],
["set -- 1; for x do scan_probe; done", ["set", "scan_probe"]],
["'q'; x=1 a", ["q", "a"]],
["cat <<E; ( true\nscan_ignored\nE\n)", ["cat", "true"]],
["cat <<E; f() { true\nscan_ignored\nE\n}; f", ["cat", "true", "f"]],
["cat <<E; case x in\nscan_ignored\nE\nx) scan_probe;; esac", ["cat", "scan_probe"]],
["time [[ ( -f foo ) ]]", []],
["time ! { echo a; echo b; }", ["echo", "echo"]],
] as const
describe("ordinary Bash and Zsh syntax", () => {
test.each(fixtures)("extracts actual command nodes: %s", (source, names) => {
for (const dialect of ["bash", "zsh"] as const) {
const result = ShellScan.scan(source, dialect)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
}
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
for (const [source] of fixtures) {
// These are Bash spellings; Zsh's fd allocation is a standalone statement. Bash parses extglob
// patterns only when extglob is enabled.
// These are Bash spellings; Zsh's fd allocation is a standalone statement.
test.skipIf(
!executable ||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))) ||
(shell === "bash" && source.includes("@(")),
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))),
)(`${shell} accepts the source grammar: ${source}`, () => {
const result = Bun.spawnSync([
executable ?? shell,
@@ -150,15 +132,10 @@ describe("ordinary Bash and Zsh syntax", () => {
"cat <<EOF\nunclosed",
"echo ${missing",
"echo $'missing",
"case $r in a) ls |;; esac",
])("rejects incomplete syntax: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test("scans a Zsh brace group closed after a redirect", () => {
expect(ShellScan.scan("{ a && >f }")).toMatchObject({ kind: "scanned", commands: [{ words: ["a"] }] })
})
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
expect(ShellScan.scan("$'pri\\x6etf' $'line\\n' $\"text\"")).toMatchObject({
kind: "scanned",
@@ -168,7 +145,7 @@ describe("ordinary Bash and Zsh syntax", () => {
test.each([
["coproc job { scan_probe; }", ["scan_probe"]],
["case x in @(one|$(scan_probe))) ;; esac", ["scan_probe"]],
["printf '%s' @(one|$(scan_probe))", ["printf", "scan_probe"]],
["printf '%s' $((1 + '$(scan_probe)'))", ["printf", "scan_probe"]],
["printf '%s' $(((1 + '$(scan_probe)')))", ["printf", "scan_probe"]],
['printf %s "${ scan_probe; }"', ["printf", "scan_probe"]],
@@ -264,56 +241,3 @@ describe("Bash shared heredoc delimiter grammar", () => {
)
})
})
describe("Bash dialects", () => {
const heads = (source: string, dialect?: ShellScan.Dialect) => {
const result = ShellScan.scan(source, dialect)
return result.kind === "scanned" ? result.commands.map((command) => command.words[0]) : result.kind
}
test("posix reports both readings of a double parenthesis", () => {
expect(heads("(( x = 1 )); y", "bash")).toEqual(["y"])
expect(heads("(( x = 1 )); y", "zsh")).toEqual(["y"])
expect(heads("(( x = 1 )); y", "posix")).toEqual(["x", "y"])
expect(heads("(( x = 1 )); y")).toEqual(["x", "y"])
expect(heads("(( (1) + (2) ))", "bash")).toEqual([])
expect(heads("(( (1) + (2) ))", "posix")).toBe("opaque")
})
test.each([
["true &>/dev/null next", ["true"], "opaque"],
["X=$[1 + 2] next", ["next"], "opaque"],
] as const)("reads Bash and Zsh syntax precisely where Dash diverges: %s", (source, precise, posix) => {
expect(heads(source, "bash")).toEqual([...precise])
expect(heads(source, "zsh")).toEqual([...precise])
expect(heads(source, "posix")).toEqual(posix)
})
test("reads a reserved word after a redirect as a Zsh keyword", () => {
const source = "if true; then >/dev/null fi; next"
expect(heads(source, "zsh")).toEqual(["true", "next"])
expect(heads(source, "bash")).toBe("opaque")
expect(heads(source, "posix")).toBe("opaque")
})
test("scans safe Zsh parameter flags, Zsh repeat loops, and Bash extglob arguments", () => {
expect(heads("print -l ${(M)files:#*.ts}", "zsh")).toEqual(["print"])
expect(heads("print -l ${(ps:\\n:)text}", "zsh")).toEqual(["print"])
expect(heads("print -r -- ${(q-)x} ${(q+)x} ${(on-)x}", "zsh")).toEqual(["print"])
expect(heads("repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
expect(heads("repeat 3; do echo hi; done", "posix")).toEqual(["echo"])
expect(heads("f() repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
expect(heads("ls @(foo|bar)", "bash")).toEqual(["ls"])
})
test.each([
["/bin/bash", ["y"]],
["/usr/local/bin/zsh", ["y"]],
["/bin/sh", ["x = 1", "y"]],
["/bin/dash", ["x = 1", "y"]],
["/opt/bin/mksh", ["x = 1", "y"]],
] as const)("derives the dialect from the shell executable: %s", async (shell, resources) => {
const result = await Effect.runPromise(ShellParse.scanPortable("(( x = 1 )); y", shell, "/workspace"))
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
})
})
@@ -1,751 +0,0 @@
import { afterAll, describe, expect, test } from "bun:test"
import { Effect, Exit } from "effect"
import fs from "fs"
import os from "os"
import path from "path"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const shellCandidates = ["/bin/bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "bash", "zsh", "dash"]
const shells = [
...new Set(
shellCandidates
.map((item) => (item.startsWith("/") ? (fs.existsSync(item) ? item : undefined) : Bun.which(item)))
.filter((item): item is string => Boolean(item)),
),
]
// Fixtures target specific shells and versions (bash 3.2 and 5.3, zsh, dash). Set SHELL_ORACLE_STRICT=1 on a
// machine with all of them to also verify that every fixture still executes somewhere.
const strict = process.env.SHELL_ORACLE_STRICT === "1"
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-shell-oracle-"))
const bin = path.join(root, "bin")
const target = path.join(root, "target")
const log = path.join(root, "log")
fs.mkdirSync(bin)
fs.mkdirSync(target)
fs.writeFileSync(
path.join(bin, "scan_probe"),
'#!/bin/sh\nprintf \'%s\\n\' "scan_probe${1:+ $*}" >> "$SCAN_PROBE_LOG"\n',
{
mode: 0o755,
},
)
// Dash runs `scan_probe[x y]=1` as the command `scan_probe[x`.
fs.copyFileSync(path.join(bin, "scan_probe"), path.join(bin, "scan_probe[x"))
// Zsh treats a trailing parenthesized group after an existing file name as glob qualifiers.
fs.writeFileSync(path.join(root, "a="), "")
afterAll(() => fs.rmSync(root, { recursive: true, force: true }))
function shellFlags(executable: string) {
if (executable.endsWith("/bash")) return ["--noprofile", "--norc"]
if (executable.endsWith("/zsh")) return ["-f"]
return []
}
// Runs source in a real shell and returns the lines it logged.
function observe(executable: string, source: string) {
fs.writeFileSync(log, "")
Bun.spawnSync([executable, ...shellFlags(executable), "-c", source], {
cwd: root,
env: { PATH: `${bin}:/usr/bin:/bin`, HOME: root, LC_ALL: "C", SCAN_PROBE_LOG: log },
timeout: 2_000,
})
return fs
.readFileSync(log, "utf8")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
}
// Each dialect must report what its shells run; posix, the default, covers every shell.
const dialects = {
bash: (executable: string) => path.basename(executable) === "bash",
zsh: (executable: string) => path.basename(executable) === "zsh",
posix: () => true,
} satisfies Record<ShellScan.Dialect, (executable: string) => boolean>
function expectProbesReported(source: string) {
const runs = shells.map((executable) => [executable, observe(executable, source)] as const)
if (strict)
expect(
runs.some(([, invocations]) => invocations.length > 0),
`Fixture never executed scan_probe in any real shell: ${source}`,
).toBe(true)
expect(ShellScan.scan(source)).toEqual(ShellScan.scan(source, "posix"))
for (const [dialect, runsIn] of Object.entries(dialects)) {
const result = ShellScan.scan(source, dialect as ShellScan.Dialect)
if (result.kind === "opaque") continue
const reported = result.commands.filter((command) => !command.declaration).map((command) => command.words.join(" "))
for (const [executable, invocations] of runs.filter(([executable]) => runsIn(executable)))
for (const invocation of invocations)
expect(reported, `${executable} executed ${invocation} with ${dialect} in: ${source}`).toContain(invocation)
}
}
// Each fixture runs scan_probe in at least one real shell, which the scanner must report or reject.
const fixtures = [
"a=(1)scan_probe",
"a=(\n1)scan_probe; ",
"a=(1)'scan_probe'",
"alias p=scan_probe\np",
"command alias p=scan_probe\np",
"x='*(e:scan_probe:)'; echo $^~x",
"x='$(scan_probe)'; echo \"${\\\n(e)x}\"",
"x='$(scan_probe)'; echo ${(j:):e)x}",
"x='*(e:scan_probe:)'; echo ${(f)~x}",
"x='*(e:scan_probe:)'; echo ${=~x}",
"x='*(e:scan_probe:)'; echo $=~x",
"x='$(scan_probe)'; echo ${(%e)x}",
"x='$(scan_probe)'; echo ${(s.:.e)x}",
"x='$(scan_probe)'; echo ${(s(:)e)x}",
"x='$(scan_probe)'; echo ${(ej: :)x}",
"x='*(e:scan_probe:)'; echo ${(s: :)~x}",
"x='*(e:scan_probe:)'; echo ${^~x}",
"printf -v x 'a[$(scan_probe)]'; echo $((x))",
"a=(1); getopts a: x -a 'a[$(scan_probe)]'; echo $((OPTARG))",
"command -- declare 'a[$(scan_probe)]=1'",
"a=(1); unset 'a[b[$\\\n(scan_probe)0]]'",
'declare \'a["\\"]"$(scan_probe)0]=1\'',
"declare -a 'a=(+ [$(scan_probe)]=1)'",
"a=(1); echo ${a[b[\\$(scan_probe)]]}",
'a["b[\\$(scan_probe)]"]=1',
'b=1; a["b[\\$(scan_probe)1]"]=1',
"a=(1); echo $[ ${x:-a[\\$(scan_probe)1]} ]",
"a=(1); s=abc; echo ${s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${s:${x:-'a[$(scan_probe)1]'}}",
"read x <<< 'a[$(scan_probe f)]'; echo $((x))",
"mapfile -t a <<< 'b[$(scan_probe g)]'; echo $((a[0]))",
"x=1; echo $(( x[\\$(scan_probe i)] ))",
"a=(1); echo ${a[@]:'a[$(scan_probe k)]'}",
"builtin -- declare 'a[$(scan_probe)]=1'",
"command -p declare 'a[$(scan_probe)]=1'",
'echo "${unset:+${x[\'"\']}}"]}} \'$(scan_probe)\' " # "',
"echo \"${unset:+${x['}}\"']}}'; scan_probe # \"",
"echo \"${x:-$'\\''}\"; scan_probe # '\"",
"cat <<E\n${x:-'}'}' $(scan_probe)\nE",
'echo "${x:-a\'b}c\'d}"$(scan_probe)"\'"',
"echo \"${PATH//:/$'\\n'}\"; scan_probe ok",
"echo $(( : # ))'\n); scan_probe ) # '",
"echo $(( $(echo 1) # ))'\n); scan_probe ) # '",
"(( : # ))'\n); scan_probe ) # '",
"echo $(( 16#ff + 2#1 + $# + ${#x} )); scan_probe ok",
"(( scan_probe ))",
"(( (scan_probe) & (scan_probe) ))",
"(( (scan_probe)\n(scan_probe) ))",
"cat <<'}'; {\n:\n}\nscan_probe; cat <<'}'; }\n}",
"cat <<'x)'; case x in\nx)\nx) scan_probe; cat <<'x)'\nx)\n;; esac",
"cat <<'if'; f()\nif\nif scan_probe; cat <<'if'\nif\ntrue; then :; fi; f",
"cat <<E; ( true\nE\nscan_probe g\n)",
"cat <<E; { true\nE\nscan_probe g\n}",
"cat <<E; f() { true\nE\nscan_probe f\n}; f",
"cat <<E; if true\nE\nscan_probe i\nthen :; fi",
"cat <<E; echo $(true\nscan_probe s\n)\nE\nscan_probe after",
"cat <<E; echo `true\nscan_probe b\n`\nE\nscan_probe after",
"cat <<E; cat <(true\nscan_probe s\n)\nE\nscan_probe after",
"cat <<E; x=$(cat <<F\nF\n)\nE\nscan_probe out",
"{ cat <<E; }\nscan_probe x\nE\nscan_probe y",
"[[ a]]b# ]] && scan_probe",
'[[ "a]]"# ]] && scan_probe',
"[[ 'a]]'# ]] && scan_probe",
"[[ a]]b = a]]b ]] && scan_probe",
'{ export X="a"{}# ; scan_probe; }',
"{ export X=a}# ; scan_probe; }",
"{ case esac in (esac) scan_probe;; esac\n}",
"unset a[b\nscan_probe\necho ]+=1",
"export a[b; scan_probe; echo ]=1",
"a[b; scan_probe; echo ]=1",
"a[0 #]\n]=1; scan_probe",
"for i in 1; { scan_probe; }; while false; do export X=1; done",
"for ((i=0; i<1; i++)); { scan_probe; }; while false; do export X=1; done",
"for i in 1; scan_probe",
"((( echo '\"' ); scan_probe; ( echo '\"' )))",
"set=1; export X=${set:-${x['0\"0']}}; scan_probe; : '\"]}}' # '",
"export X=$$$$'\\'; scan_probe # '",
'export X="`export Y=\\"\'\\" ; scan_probe; export Z=\\"\'\\"`"',
'export X="`echo \\"(\\"; scan_probe; echo \\")\\"`"',
": <<-export\n\tex\\\n\tport\n$(scan_probe)\nexport",
'export X="${\\\n scan_probe; }"',
'export X="${\n scan_probe; }"',
'export X="${|\\\n REPLY=$(scan_probe); }"',
"!(scan_probe; true)",
"if !(scan_probe); then :; fi",
"f+() { scan_probe; }; f+",
': <<$"export"\n$export\nscan_probe\nexport',
': <<$"export"\nexport\nscan_probe\n$export',
"export X=$[1; scan_probe; : ]",
"X=$[1 scan_probe ]",
'export X=$["]"]; scan_probe # ]',
"export X=${#}# ; scan_probe",
"export X=$#a# ; scan_probe",
"export X=~+# ; scan_probe",
"{ export X=1; }# ; scan_probe; }",
"if true; then export X=1; fi# ; scan_probe; fi",
"for i in 1; do export X=1; done# ; scan_probe; done",
"case esac# in x) export X=1;; esac#) scan_probe;; esac",
": <<A <<B\nA\n$(scan_probe)\nB",
'export X="$(cat <<EOF\n)\nEOF\nscan_probe)"',
"cat <<EOF | scan_probe\nhello\nEOF",
"cat <<EOF && scan_probe\nhello\nEOF",
"cat <<'E'\"O\"F\n# $(scan_probe)\nEOF\nscan_probe",
"cat <<E\\\nOF\n$(scan_probe)\nEOF",
"cat <<-EOF\n\t EOF\n$(scan_probe)\n\tEOF",
"cat <<export\nex\\\nport\n# $(scan_probe)\nexport",
"cat <<< 'a'\"$(scan_probe)\"",
'export X=${x:-"}"}; scan_probe',
"export X=${x:-'}'}; scan_probe",
'export X="${x:-"}"}"; scan_probe',
'export X="${x:-"$(scan_probe)"}"',
"export X=${x//\\}/}; scan_probe; : }",
'export X="${x//\\}/}"; scan_probe; : "}"',
"export X=${x#${y}}; scan_probe",
'export X="${x#${y:-"}"}}"; scan_probe',
"export X=${!prefix*}; scan_probe",
"export X=${x@Q}; scan_probe",
"export X=\"${unset:+${x['\"']}}\"; scan_probe; : '\"}]}'",
'export X="${unset:+${x[\'}}\"; scan_probe; : \"\']}}"',
'echo $(case x in x) echo ")" ;; esac; scan_probe)',
"echo \"$(case x in (x) echo ')' ;; esac; scan_probe)\"",
'echo "`echo \\"$(scan_probe)\\"`"',
"echo `echo \\`scan_probe\\``",
"echo $( (echo a); scan_probe )",
"echo $((echo a); scan_probe)",
"if true; then scan_probe; elif true; then :; else :; fi",
"for i in in do done esac; do scan_probe; done",
"for do in 1; do scan_probe; done",
"for in in 1; do scan_probe; done",
"for x\nin 1; do scan_probe; done",
"set -- 1; for x\ndo scan_probe; done",
"set -- 1; for x do scan_probe; done",
"case in in in) scan_probe;; esac",
"case esac in a|esac) scan_probe;; esac",
"case y in x) ;; y) scan_probe;; esac",
"case x in x) scan_probe ;& y) : ;;& z) : ;; esac",
"case x in x) scan_probe ;| y) : ;; esac",
"case [ in [) scan_probe & ( scan_probe q ]) ;; esac",
"case x in (x|[) scan_probe & ( scan_probe q ]) ;; esac",
"while false; do :; done & scan_probe",
"{ scan_probe & }",
"( scan_probe & )",
"[[ b =~ b ]] && scan_probe",
"[[ ( a == a ) && ( b == b ) ]] && scan_probe",
'[[ "$(scan_probe)" == "]]" ]]',
"(( 1 + $(scan_probe) ))",
"(( a = 1 )) && scan_probe",
"(( (1) + (2) )); scan_probe",
"(( (echo a); scan_probe ))",
">/dev/null scan_probe",
"2>&1 scan_probe",
"A=1 >/dev/null B=2 scan_probe",
'export A=1 B="$(scan_probe)"',
'declare -a arr=(1 "$(scan_probe)")',
"export X=$'a'\\\n; scan_probe",
"case x in \\\nx) \\\nscan_probe;; \\\nesac",
"if true; then \\\nscan_probe; fi",
'() { :; } "$(scan_probe)"',
"function f() ( scan_probe ); f",
"{ export X=1 } && scan_probe",
"{ export X=1 } ; scan_probe ; }",
// Dialects disagree about reserved words after redirects and operators split by line continuations.
"if true; then >/dev/null fi; scan_probe; fi",
"if true; then >/dev/null fi; scan_probe",
"case x in x) >/dev/null esac; scan_probe; esac",
"true &>/dev/null scan_probe",
"true &>>/dev/null scan_probe",
"true &\\\n>/dev/null scan_probe",
"cat <<\\\n-EOF\nEOF\nscan_probe h1\n-EOF",
"cat <<\\\n-EOF\n-EOF\nscan_probe z\nEOF",
"cat <\\\n(scan_probe i)",
"[[ -n <\\\n(scan_probe c1) ]]",
"a=(<\\\n(scan_probe a1))",
"cat <<E\n$\\\n(scan_probe h)\nE",
"echo ${x:-$\\\n(scan_probe p)}",
"(( $\\\n(scan_probe a) ))",
"[[ $\\\n(scan_probe c) ]]",
"a=($\\\n(scan_probe arr))",
// Dash splits assignment subscripts at blanks.
"scan_probe[x y]=1",
"a[x '$(scan_probe)']=1",
"a[1 + $(scan_probe)]=1",
// Expansions inside parameter words, arithmetic, and subscripts.
"echo \"${x:-$'$(scan_probe q1)'}\"",
"echo ${x:-<(scan_probe p1)}",
"echo ${x:->(scan_probe g)}",
"x=${y:-<(scan_probe p4)}",
"[[ x == ${y:-<(scan_probe p5)} ]]",
"echo $(( $'$(scan_probe a)' ))",
"(( x = $'$(scan_probe b)' ))",
"echo $(( ${x:-'$(scan_probe a)'} ))",
"(( ${x:-'$(scan_probe b)'} ))",
"a[${x:-'$(scan_probe c)'}]=1",
"echo $[ ${x:-'$(scan_probe d)'} ]",
"echo $[ $'$(scan_probe h)' ]",
"echo ${a[${x:-'$(scan_probe e)'}]}",
"echo \"${a[${x:-'$(scan_probe f)'}]}\"",
"cat <<E\n${x:-'$(scan_probe h)'}\nE",
// Zsh glob qualifiers and extglob groups run code in globbed words.
"echo @(<(scan_probe e1))",
"printf '%s' @(one|$(scan_probe))",
"echo *(e:'scan_probe q1':)",
"echo *(+scan_probe)",
"a=(*(e:'scan_probe g':))",
"declare -a a=(*(e:'scan_probe g':))",
"export a=(*(e:'scan_probe h':))",
"for f in *(e:'scan_probe h':); do :; done",
"echo ${x:-target(e:'scan_probe p2':)}",
"echo ${x:-*(e:'scan_probe j':)}",
"echo a=(e:'scan_probe p3':)",
"echo >*(e:'scan_probe f':)",
"cat <*(e:'scan_probe g':)",
"echo $x*(e:'scan_probe h':)",
"echo \"\"*(e:'scan_probe i':)",
"echo {a,*(e:'scan_probe m':)}",
// Builtins and arithmetic evaluate subscripts in decoded literal text.
"declare -i x='a[$(scan_probe)]'",
"declare 'a[$(scan_probe)]=1'",
"declare -a 'a=([$(scan_probe)]=1)'",
"a=(1); unset 'a[$(scan_probe)]'",
'a=(1); unset "a[\\$(scan_probe)]"',
"[[ 'a[$(scan_probe)]' -eq 1 ]]",
"[[ -v 'a[$(scan_probe)]' ]]",
"read 'a[$(scan_probe)]' </dev/null",
"printf -v 'a[$(scan_probe)]' x",
"x='a[$(scan_probe)]'; echo $((x))",
"s=abc; x='a[$(scan_probe)0]'; printf '%s' \"${s:x}\"",
"ref='x[$(scan_probe)0]'; printf '%s' \"${!ref}\"",
"declare ${x:-'a[$(scan_probe)]=1'}",
'declare "${x:-a[\\$(scan_probe)]=1}"',
"read ${x:-'a[$(scan_probe)]'} </dev/null",
"declare \"$(echo 'a[$(scan_probe)]=1')\"",
"declare \"${ echo a; }\"'[$(scan_probe)]=1'",
"n=a; declare \"$n\"'[$(scan_probe)]=1'",
"declare \"$(echo a)\"'[$(scan_probe)]=1'",
"builtin declare 'a[$(scan_probe)]=1'",
"printf -v'a[$(scan_probe)]' x",
"set -- 'a[$(scan_probe)]'; echo $(($1))",
"for x in 'a[$(scan_probe)]'; do echo $((x)); done",
"a=(1 'a[$(scan_probe)]'); echo $((a[1]))",
"x='a[$(scan_probe)]' eval 'echo $((x))'",
"a=(1); echo $(( a[\\$(scan_probe)] ))",
"a=(1); (( a[\\$(scan_probe)] ))",
// Explicit evaluation operators.
"x='$(scan_probe)'; echo ${x@P}",
"x='$(scan_probe)'; echo \"${x@P}\"",
"x='$(scan_probe)'; echo ${(e)x}",
"x='$(scan_probe)'; echo ${(ee)x}",
"echo ${(e):-'$(scan_probe)'}",
"x='*(e:scan_probe:)'; echo ${~x}",
"x='*(e:scan_probe:)'; echo $~x",
// Precommand modifiers, bundled/ordered binding options, repeat, compgen, and setopt.
"noglob typeset 'a[$(scan_probe)]=1'",
"nocorrect typeset 'a[$(scan_probe)]=1'",
"a=(1); noglob unset 'a[$(scan_probe)]'",
"noglob read 'a[$(scan_probe)]' <<< x",
'a=(1); noglob let "a[\\$(scan_probe)]"',
"print -r -v 'a[$(scan_probe)]' x",
"print -rv 'a[$(scan_probe)]' x",
"a=(1); print -r -v x 'a[$(scan_probe)]'; echo $((x))",
"a=(1); print -rv x 'a[$(scan_probe)]'; echo $((x))",
": & wait -np 'a[$(scan_probe)]'",
": & wait -n -p'a[$(scan_probe)]'",
": & wait -fp 'a[$(scan_probe)]' $!",
"time declare 'a[$(scan_probe)]=1'",
"coproc declare 'a[$(scan_probe)]=1'",
"a=(1); time unset 'a[$(scan_probe)]'",
"a=(1); time let 'a[$(scan_probe)]'",
"a=(1); time [[ -v 'a[$(scan_probe)]' ]]",
"a=(1); time [[ 'a[$(scan_probe)]' -eq 1 ]]",
"a=(1); coproc [[ -v 'a[$(scan_probe)]' ]]",
'export X="${ time ! { :; }; scan_probe; }"',
'export X="${ time\\\n { :; }; scan_probe; }"',
'export X="${ time -- { :; }; scan_probe; }"',
"repeat 1 scan_probe",
"a=(1); repeat 'a[$(scan_probe)]' :",
"setopt globsubst; x='*(e:scan_probe:)'; echo $x",
"set -o globsubst; x='*(e:scan_probe:)'; echo $x",
"compgen -C scan_probe",
"compgen -C 'scan_probe'",
// Combined literal/output subscripts, deferred bindings, continuations, Zsh flags, and ANSI-C escapes.
"declare \"a[$(echo '$(scan_probe)')]=1\"",
"declare \"$(echo 'a[')\"'$(scan_probe)]=1'",
"a=(1); unset \"a[$(echo '$(scan_probe)')]\"",
"a=(1); echo $(( a[$(echo '$(scan_probe)')] ))",
"x='$(scan_probe)'; declare \"a[$x]=1\"",
"x='$(scan_probe)'; a=(1); unset \"a[$x]\"",
"x='$(scan_probe)'; let \"a[$x]=1\"",
"x='$(scan_probe)'; read \"a[$x]\" <<< 1",
"x='$(scan_probe)'; printf -v \"a[$x]\" 1",
"a=(1); x='$(scan_probe)'; echo $((a[$x]))",
"a=(1); x='$(scan_probe)'; [[ -v \"a[$x]\" ]]",
"a=(1); x='$(scan_probe)'; [[ \"a[$x]\" -eq 1 ]]",
"a=(1); echo ${a\\\n['$(scan_probe)']}",
"a=(1); s=abc; echo ${s\\\n:'a[$(scan_probe)0]'}",
"a=(1); echo ${a[0]\\\n:'a[$(scan_probe)0]'}",
"x='$(scan_probe)'; echo ${x@\\\nP}",
"x='$(scan_probe)'; echo \"${x@\\\nP}\"",
"x='$(scan_probe)'; echo ${x@P\\\n}",
"x='*(e:scan_probe:)'; echo $\\\n~x",
"x='*(e:scan_probe:)'; echo $^\\\n~x",
"x='*(e:scan_probe:)'; echo ${^\\\n~x}",
"x='*(e:scan_probe:)'; echo ${(f)\\\n~x}",
"a=(1); echo ${(f)a['a[$(scan_probe)]']}",
"a=(1); echo ${=a['a[$(scan_probe)]']}",
"a=(1); echo ${^a['a[$(scan_probe)]']}",
"a=(1); s=abc; echo ${(f)s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${=s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${^s:'a[$(scan_probe)0]'}",
"a=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"a=(1); [[ x != *(a)]] && -v 'a[$(scan_probe)]' ]]",
"a=(1); [[ x != (a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"shopt -s extglob\na=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"a=(1); echo $(( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
"a=(1); (( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
"a=(1); echo ${a[$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d']}",
"a=(1); declare \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d=1'}\"",
"a=(1); unset \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d'}\"",
"(( $'\\' )) # ' )); scan_probe",
"declare -A a; a[$'\\']=1 # ']=1; scan_probe",
"a=(1); echo ${a[$'\\']} # ']} $(scan_probe)",
": <<$'export'\n$export\nscan_probe\nexport",
": <<$'EOF'\n$EOF\nscan_probe\nEOF",
"echo $'\\'; scan_probe; : ' # '",
"export X=$'\\'; scan_probe; : ' # '",
"export X=$'\\c\\\\'; scan_probe # '",
"export X=$'\\c'; scan_probe; : ' # '",
"$'scan_probe\\0x'",
"$'scan_probe\\x00x'",
"$'scan_probe\\u0000x'",
"$'scan_probe\\c@x'",
"$'declare\\0x' 'a[$(scan_probe)]=1'",
"a=(1); $'unset\\0x' 'a[$(scan_probe)]'",
"$'let\\0x' 'a[$(scan_probe)]'",
"x='x y'; scan_probe[$x]=1",
// Parameter default bindings, Zsh subscripts, precommands, namerefs, ANSI-C escapes, and Zsh builtins.
"a=(1); : ${x:='a[$(scan_probe)]'}; echo $((x))",
"a=(1); : ${x:='$(scan_probe)'}; echo $((a[$x]))",
"a=(1); : ${x='$(scan_probe)'}; unset \"a[$x]\"",
'a=(1); : "${x:="a[\\$(scan_probe)]"}"; echo $((x))',
"a=(); : ${a[0]:='a[$(scan_probe)]'}; echo $((a[0]))",
"declare -i a x; : ${x:='a[$(scan_probe)]'}",
"a=(1); echo ${@[1,'a[$(scan_probe)]']}",
"a=(1); echo ${*[1,'a[$(scan_probe)]']}",
"a=(1); echo ${1[1,'a[$(scan_probe)]']}",
"a=(1); echo ${a[1]['$(scan_probe)']}",
"a=(1); echo ${s[1][1]:'a[$(scan_probe)0]'}",
"a=(1); echo ${${a}['$(scan_probe)']}",
"a=(1); echo ${${s}:'a[$(scan_probe)0]'}",
"a=(1); echo $a['$(scan_probe)']",
'a=(1); echo "$a[a[\\$(scan_probe)]]"',
"a=(1); x='$(scan_probe)'; echo $a[a[$x]]",
"a=(1); x='$(scan_probe)'; echo \"$a[a[$x]]\"",
"a=(1); echo $@[1,'a[$(scan_probe)]']",
"a=(1); echo $^a['$(scan_probe)']",
"exec typeset 'a[$(scan_probe)]=1'",
"a=(1); - typeset 'a[$(scan_probe)]=1'",
"a=(1); exec unset 'a[$(scan_probe)]'",
"a=(1); - unset 'a[$(scan_probe)]'",
"a=(1); exec -a foo unset 'a[$(scan_probe)]'",
"a=(1); exec let 'a[$(scan_probe)]'",
"a=(1); - let 'a[$(scan_probe)]'",
"unset a=(e:'scan_probe':)",
"unsetenv a=(e:'scan_probe':)",
"a=(1); r='a[$(scan_probe)]'; declare -n r; r=1",
"a=(1); r='a[$(scan_probe)]'; declare -n r; echo $r",
"a=(1); r='a[$(scan_probe)]'; typeset -n r; r=1",
"a=(1); x='a[$(scan_probe)]'; (( $'\\170' ))",
"a=(1); x='a[$(scan_probe)]'; echo ${a[$'\\170']}",
"a=(1); x='a[$(scan_probe)]'; declare a[x]=1",
"a=(1); x='a[$(scan_probe)]'; declare 'a[x]=1'",
"a=(1); x='a[$(scan_probe)]'; unset 'a[x]'",
"a=(1); x='a[$(scan_probe)]'; [[ -v a[x] ]]",
"a=(1); x='a[$(scan_probe)]'; read 'a[x]' <<< 1",
"a=(1); x='a[$(scan_probe)]'; printf -v 'a[x]' 1",
"a=(1); x='a[$(scan_probe)]'; test -v 'a[x]'",
"a=(1); echo ${(s:):)a['a[$(scan_probe)]']}",
"a=(1); s=abc; echo ${(s:):)s:'a[$(scan_probe)0]'}",
"a=(1); [[ !(-v 'a[$(scan_probe)]') ]]",
"echo $'a\\'; scan_probe; ' \\'",
"echo $'\\'$(scan_probe)' \\'",
": <<$'a\\'; scan_probe; '\\'\n$a\\\n\na'; scan_probe; '",
"export X=$'\\c\\''; scan_probe # '",
"a=(1); declare $'a[\\0$(scan_probe)]=1'",
"a=(1); unset $'a[\\0$(scan_probe)]'",
"a=(1); declare $'a[\\u0000$(scan_probe)]=1'",
": <<$'\\u0041'\n\\u0041\nscan_probe\nA",
": <<$'\\U00000041'\n\\U00000041\nscan_probe\nA",
"$'s\\can_probe'",
"$'de\\clare' 'a[$(scan_probe)]=1'",
"$'\\scan_probe'",
"a=(1); typeset $'a\\[\\$\\(scan_probe\\)\\]=1'",
"$'scan_probe\\xz'",
"$'scan_probe\\uz'",
"$'scan_probe\\Uz'",
"$'scan_probe\\C-@x'",
"$'scan_probe\\C@x'",
"a=(1); set -A 'a[$(scan_probe)]' 1",
"a=(1); set +A 'a[$(scan_probe)]' 1",
"unsetopt noglobsubst; x='*(e:scan_probe:)'; echo $x",
"set +o noglobsubst; x='*(e:scan_probe:)'; echo $x",
"set -xo globsubst; x='*(e:scan_probe:)'; echo $x",
"emulate zsh -o globsubst; x='*(e:scan_probe:)'; echo $x",
"NULLCMD=scan_probe; >/dev/null",
"READNULLCMD=scan_probe; </dev/null",
"NULLCMD=scan_probe; <<< x",
"setopt promptsubst; x='$(scan_probe)'; print -P $x",
"setopt promptvars; print -P '$(scan_probe)'",
"a=(1); exit 'a[$(scan_probe)]'",
"f() { return 'a[$(scan_probe)]'; }; a=(1); f",
"a=(1); shift 'a[$(scan_probe)]'",
"a=(1); for x in 1; do break 'a[$(scan_probe)]'; done",
"a=(1); for x in 1; do continue 'a[$(scan_probe)]'; done",
"a=(1); bye 'a[$(scan_probe)]'",
"a=(1); logout 'a[$(scan_probe)]'",
"a=(1); getln 'a[$(scan_probe)]'",
"a=(1); vared 'a[$(scan_probe)]'",
"a=(1); zformat -f 'a[$(scan_probe)]' x",
"a=(1); zstyle -s : x 'a[$(scan_probe)]'",
"zmodload zsh/stat; a=(1); zstat -A 'a[$(scan_probe)]' .",
"a=(1); compgen -V x -W 'a[$(scan_probe)]'; echo $((x[0]))",
// Special/positional subscripts, escape decoders, split expansions, float/array declarations, and continuations.
"a=(1); set -- 1 2 3 4 5 6 7 8 9 abc; echo ${1\\\n0:'a[$(scan_probe)0]'}",
"a=(1); echo ${1\\\n0[1,'a[$(scan_probe)]']}",
"a=(1); echo $0[1,'a[$(scan_probe)]']",
"a=(1); echo $?[1,'a[$(scan_probe)]']",
"a=(1); echo $#[1,'a[$(scan_probe)]']",
"a=(1); echo $-[1,'a[$(scan_probe)]']",
"a=(1); echo $![1,'a[$(scan_probe)]']",
"a=(1); echo $$[1,'a[$(scan_probe)]']",
'a=(1); echo "$?[1,a[\\$(scan_probe)]]"',
'a=(1); echo "$$[1,a[\\$(scan_probe)]]"',
"a=(1); x='a[\\x24(scan_probe)]'; unset \"${x@E}\"",
"a=(1); x='a[\\x24(scan_probe)]'; declare \"${x@E}=1\"",
"a=(1); x='a[\\x24(scan_probe)]'; y=${x@E}; echo $((y))",
'a=(1); x="$\'a[\\\\x24(scan_probe)]\'"; unset "${(Q)x}"',
'a=(1); x="$\'a[\\\\x24(scan_probe)]\'"; typeset "${(Q)x}=1"',
"a=(1); x=\"$'a[\\\\x24(scan_probe)]'\"; y=${(Q)x}; echo $((y))",
"a=(1); x='a[$'; x+='(scan_probe)]'; unset \"$x\"",
'a=(1); d=\'$\'; x="a[${d}(scan_probe)]"; unset "$x"',
"a=(1); x='a[$_1(scan_probe)]'; unset \"${x/_1/}\"",
"a=(1); x='a[$_1(scan_probe)]'; echo $(( ${x/_1/} ))",
"a=(1); x='a[$'; y='(scan_probe)]'; echo $(( $x$y ))",
"a=(1); read x <<< 'a[$\\(scan_probe)]'; echo $((x))",
"a=(1); printf -v x 'a[\\x24(scan_probe)]'; echo $((x))",
"a=(1); print -v x 'a[\\x24(scan_probe)]'; echo $((x))",
"a=(1); typeset -E x; x='a[$(scan_probe)]'",
"a=(1); typeset -F x; x='a[$(scan_probe)]'",
"a=(1); y='a[$(scan_probe)]'; typeset -E x=y",
"a=(1); y='a[$(scan_probe)]'; typeset -F x=y",
"a=(); declare a='([$(scan_probe)]=1)'",
"a=(); typeset a='([$(scan_probe)]=1)'",
"a=(); declare a+='([$(scan_probe)]=1)'",
"f() { local -a a; local a='([$(scan_probe)]=1)'; }; f",
"declare -a a; x='([$(scan_probe)]=1)'; declare a=$x",
"x=${NULLCMD::=scan_probe}; >/dev/null",
"x=${READNULLCMD::=scan_probe}; </dev/null",
": a${NULLCMD::=scan_probe}; >/dev/null",
": ${^NULLCMD::=scan_probe}; >/dev/null",
": ${(f)NULLCMD::=scan_probe}; >/dev/null",
"print -vNULLCMD scan_probe; >/dev/null",
"(( ${NULLCMD::=scan_probe} )); >/dev/null",
"[[ -n ${NULLCMD::=scan_probe} ]]; >/dev/null",
"a=(${NULLCMD::=scan_probe}); >/dev/null",
": <<EOF\n${NULLCMD::=scan_probe}\nEOF\n>/dev/null",
"PS4='$(scan_probe)'; set -x; x=1",
"PS4='$(scan_probe)'; set -o xtrace; x=1",
"export PS4='$(scan_probe)'; set -x; x=1",
"setopt -m 'glob*'; x='*(e:scan_probe:)'; echo $x",
"setopt -m '*subst*'; x='*(e:scan_probe:)'; echo $x",
"emulate sh; unsetopt shglob; setopt bareglobqual; x='*(e:scan_probe:)'; echo $x",
"emulate ksh; unsetopt shglob; setopt bareglobqual; x='*(e:scan_probe:)'; echo $x",
"a=(1); zregexparse 'a[$(scan_probe)]' 'a[$(scan_probe)]' x /x/",
"a=(1); private 'a[$(scan_probe)]=1'",
"a=(1); [[ x =~ (a )(b)]] || 0 -eq 'a[$(scan_probe)]' ]]",
"a=(1); [[ x =~ (a )(b)]] || -v 'a[$(scan_probe)]' ]]",
"a=(1); [[ -t 'a[$(scan_probe)]' ]]",
"a=(1); x='a[$(scan_probe)]'; [[ -t x ]]",
"a=(1); x='a[$(scan_probe)]'; [[ -t $x ]]",
"a=(1); test -t 'a[$(scan_probe)]'",
"a=(1); [ -t 'a[$(scan_probe)]' ]",
"a=(1); x='a[$(scan_probe)]'; test -t x",
"a=(1); x='a[$(scan_probe)]'; [ -t x ]",
"echo $\\\n'a\\''; scan_probe; : \\'",
": <<$\\\n'EOF'\nEOF\nscan_probe\n$EOF",
': <<$\\\n"EOF"\nEOF\nscan_probe\n$EOF',
"$\\\n'scan_probe'",
'$\\\n"scan_probe"',
"a=(1); unset $\\\n'a[\\x24(scan_probe)]'",
"echo \"${x:-$\\\n'\\x24(scan_probe)'}\"",
"[[ -n x ]\\\n] && scan_probe && [[ -n y ]]",
"a=(1); (\\\n( 'a[$(scan_probe)]' ))",
"a=(1); (\\\n( 1 + 'a[$(scan_probe)]' ))",
"a=(1); echo $(\\\n( 'a[$(scan_probe)]' ))",
"a=(1); x='a[$(scan_probe)]'; echo $(( x )\\\n)",
"a=(1); [\\\n[ -v 'a[$(scan_probe)]' ]]",
"a=(1); [\\\n[ 0 -eq 'a[$(scan_probe)]' ]]",
"a[0]\\\n=1 scan_probe",
"a[0]+\\\n=1 scan_probe",
"a=(); a[0]\\\n='a[$(scan_probe)]'; echo $((a[0]))",
"x='x y'; scan_probe[$x]\\\n=1",
"a=(1); x='a[$_1]'; y='(scan_probe)'; unset \"${x/_1/$y}\"",
"a=(1); x='a[$_1]'; y='(scan_probe)'; echo $(( ${x/_1/$y} ))",
"a=(1); x='a[$1'; y='(scan_probe)]'; echo $(( ${x%1}$y ))",
"a=(1); x='a[$1\n(scan_probe)]'; unset \"${x/1?/}\"",
"printf -v x 'a[\\x24\\x28scan_probe\\x29]'; echo $((x))",
"printf -v x 'a[\\044\\050scan_probe\\051]'; echo $((x))",
"a=(1); printf -v x 'a[\\u0024(scan_probe)]'; echo $((x))",
"a=(1); printf -v x 'a[\\U00000024(scan_probe)]'; echo $((x))",
"typeset -a a; print -v x 'a[\\x24\\x28scan_probe\\x29]'; echo $((x))",
"typeset -a a; print -v x 'a[\\044\\050scan_probe\\051]'; echo $((x))",
"typeset -a a; print -v x 'a[\\u0024\\u0028scan_probe\\u0029]'; echo $((x))",
"typeset -a a; print -v x 'a[\\U00000024\\U00000028scan_probe\\U00000029]'; echo $((x))",
'a=(1); x="$\'a[\\\\x24\\\\x28scan_probe\\\\x29]\'"; unset "${x:Q}"',
'a=(1); x="$\'a[\\\\x24\\\\x28scan_probe\\\\x29]\'"; unset "$x:Q"',
"a=(1); x=\"$'a[\\\\x24\\\\x28scan_probe\\\\x29]'\"; y=${x:Q}; echo $((y))",
"a=(1); read x <<'EOF'\na[$(scan_probe)]\nEOF\necho $((x))",
"a=(1); read x <<EOF\na[\\$(scan_probe)]\nEOF\necho $((x))",
"mapfile -t a <<'EOF'\nb[$(scan_probe)]\nEOF\necho $((a[0]))",
"a=(); declare a=\"$(echo '([$(scan_probe)]=1)')\"",
"a=(); declare a=\"`echo '([$(scan_probe)]=1)'`\"",
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$1",
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$@",
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$*",
"a=(); set -- '([$(scan_probe)]=1)'; declare a=\"$1\"",
"x==(scan_probe)",
"x+==(scan_probe)",
"a=(1); a[1]==(scan_probe)",
"a=(1); a[1]+==(scan_probe)",
"export x==(scan_probe)",
"typeset x==(scan_probe)",
"f() { local x==(scan_probe); }; f",
"x==\\\n(scan_probe)",
"a=(1); ref='a[$(scan_probe)]'; echo ${\\\n!ref}",
"a=(1); ref='a[$(scan_probe)]'; echo \"${\\\n!ref}\"",
"a=(1); set -k; f() { echo $((x)); }; f x='a[$(scan_probe)]'",
"a=(1); set -o keyword; f() { echo $((x)); }; f x='a[$(scan_probe)]'",
"a=(1); set -k; test -v 'a[x]' x='a[$(scan_probe)]'",
"a=(1); set -o posix -k; : x='a[$(scan_probe)]'; echo $((x))",
"set -oglobsubst; x='*(e:scan_probe:)'; echo $x",
"set +onoglobsubst; x='*(e:scan_probe:)'; echo $x",
"setopt -oglobsubst; x='*(e:scan_probe:)'; echo $x",
"setopt +onoglobsubst; x='*(e:scan_probe:)'; echo $x",
"printf -vPS4 '\\x24(scan_probe)'; set -x; x=1",
"printf -vPS4 '\\044(scan_probe)'; set -x; x=1",
"functions[zshexit]=scan_probe",
"functions[TRAPEXIT]=scan_probe",
"functions[TRAPDEBUG]=scan_probe; x=1",
"functions=(zshexit scan_probe)",
": ${functions[zshexit]::=scan_probe}",
"functions[echo]=scan_probe; echo hi",
"options[globsubst]=on; x='*(e:scan_probe:)'; echo $x",
"options=(globsubst on); x='*(e:scan_probe:)'; echo $x",
": ${options[globsubst]::=on}; x='*(e:scan_probe:)'; echo $x",
"aliases[echo]=scan_probe; options[aliases]=on; : `echo`",
"galiases[echo]=scan_probe; options[aliases]=on; : `echo`",
"saliases[x]=scan_probe; options[aliases]=on; : `a.x`",
"commands[ls]=$(command -v scan_probe); ls",
"a=(1); [[ x == \"\" || ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x != '' && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x != '' && ( -v 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x != '' && ( -t 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ == && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ -n == && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x == != || ( -t 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x == == || ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ ( -n == ) && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"[[ -n $$'\\'' ]]; scan_probe # ' ]]",
"a=($$'\\''); scan_probe # ')",
"echo ${x:-$$'\\''}; scan_probe # '}",
"export X=$$\\\n'\\''; scan_probe # '",
"export X=$'\\'; scan_probe; : '; cat <<E\n'\nE",
"export X=$\\\n$'\\'; scan_probe; : ' # '",
'a=(1); echo "$a[(r)\\],a[\\$(scan_probe)]]"',
'a=(1); echo "$?[1\\],a[\\$(scan_probe)]]"',
'export X="${unset:+${a[0}}"; scan_probe; : "]}}"',
"export X=${unset:+${a[0}}; scan_probe; : ]}}",
'export X=${unset:+${a["0}"}}; scan_probe; : ]}}',
'cat <<E; export X="${unset:+$(( 0 + "0 ))}" && scan_probe\n))}"\nE',
'false && export X="${unset:+$(( 0 }"; scan_probe; : "))}"',
'false && export X="${unset:+$(echo a}"; scan_probe; : ")}"',
'false && export X="${unset:+$(echo {a}"; scan_probe; : ")}"',
'false && export X="${unset:+$(echo a})}"; scan_probe; : "}"',
"export X=$$'\\' # '; scan_probe",
"export X=$$'\\' #\nexport Y='; scan_probe # '",
"export X=$^$'\\' # '; scan_probe",
"export X=$#$'\\' # '; scan_probe",
"export X=$'\\'\nscan_probe\n'",
] as const
// These also run inside every wrapper below.
const nestedFixtures = [
"[[ a]]# ]] && scan_probe",
"{ export X={}# ; scan_probe; }",
"unset a[b; scan_probe; echo ]=1",
"case esac in (esac) scan_probe;; esac",
"case esac in (a|esac) scan_probe;; esac",
"for i in 1; { if true; then scan_probe; fi }; while false; do export X=1; done",
"(( echo '\"' ); scan_probe; ( echo '\"' ))",
"export X=${unset:+${x['0\"0']}}; scan_probe; : '\"]}}' # '",
"export X=$$'\\'; scan_probe # '",
": <<-export\n\tex\\\n\tport\n# $(scan_probe)\nexport",
"!(scan_probe)",
"f+() case x in x) scan_probe;; esac; f+",
"f@g() case x in x) scan_probe;; esac; f@g",
"f@g() for i in 1; do scan_probe; done; f@g",
] as const
const wrappers: Array<[name: string, wrap: (inner: string) => string]> = [
["$(...)", (inner) => `export OUTER=$( ${inner}\n)`],
['"$(...)"', (inner) => `export OUTER="$( ${inner}\n)"`],
["backticks", (inner) => `export OUTER=\` ${inner}\n\``],
["heredoc", (inner) => `: <<EOF\n$( ${inner}\n)\nEOF`],
["case arm", (inner) => `case x in x) ${inner}\n;; esac`],
["for loop", (inner) => `for k in 1; do ${inner}\ndone`],
["function", (inner) => `wrap_fn() {\n${inner}\n}; wrap_fn`],
["brace group", (inner) => `{ ${inner}\n}`],
]
describe.skipIf(process.platform === "win32")("real-shell soundness oracle", () => {
test("discovers at least bash on PATH", () => {
expect(shells.some((item) => item.endsWith("/bash"))).toBe(true)
})
test.each([...fixtures, ...nestedFixtures])("reports or rejects real-shell execution: %j", (source) => {
expectProbesReported(source)
})
test.each(wrappers.flatMap(([name, wrap]) => nestedFixtures.map((source) => [name, source, wrap(source)])))(
"reports or rejects in %s: %j",
(_, __, source) => {
expectProbesReported(source)
},
)
})
describe.skipIf(process.platform === "win32")("real-shell directory oracle", () => {
test.each(["cd >/dev/null TARGET", "cd 2>/dev/null TARGET", "cd $'TARGET'", 'cd "TARGET"'])(
"reports the directory a real shell changes to: %s",
async (template) => {
const source = template.replace("TARGET", target)
const targets = [target, fs.realpathSync(target)]
const changed = shells.filter((executable) =>
targets.includes(observe(executable, `${source}\npwd >> "$SCAN_PROBE_LOG"`).at(-1) ?? ""),
)
expect(changed.length, `Fixture never changed directory in any real shell: ${source}`).toBeGreaterThan(0)
for (const executable of changed) {
const parsed = await Effect.runPromiseExit(ShellParse.scanPortable(source, executable, root))
if (Exit.isSuccess(parsed)) expect(parsed.value.directories, `${executable} in: ${source}`).toContain(target)
}
},
)
})
+1 -13
View File
@@ -38,11 +38,6 @@ describe("Bash redirect resource oracle", () => {
`${redirect} FOO=bar git status 3>tail`,
`npm run ${redirect} test`,
]) {
// Dash reads `&>` as `&` and `>`, so words after its target start another command there.
if (redirect.startsWith("&") && !command.endsWith(redirect)) {
expect(ShellScan.scan(command).kind).toBe("opaque")
continue
}
await parity(command)
for (const separator of separators) {
await parity(`printf ok${separator}${command}`)
@@ -85,6 +80,7 @@ describe("Bash redirect resource oracle", () => {
"pwd | cat 2\\>out",
"if true; then printf ok && cat >$(printf path); fi",
"if true; then printf ok && git >out status; else cat >log; fi",
"pwd && cd >out /outside",
"time git status",
"time -p git status",
"coproc git status",
@@ -155,12 +151,4 @@ describe("Bash redirect resource oracle", () => {
{ resource: "FOO=bar >output git status", save: "git status *" },
])
})
test("known gap: redirect before cd operand retains the target directory natively", async () => {
const source = "pwd && cd >out /outside"
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(legacy).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: [] })
expect(native).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["/outside"] })
})
})
+2 -2
View File
@@ -217,8 +217,6 @@ describe("ShellScan", () => {
["CDPATH=/usr # comment\ncd bin; rm victim", ["cd", "rm"]],
["HOME=/etc # comment\ncd; rm victim", ["cd", "rm"]],
["VALUE=$(printf 2); echo $((VALUE + 1))", ["printf", "echo"]],
["MSG='Use ${FOO} here'; some_cmd || exit $?", ["some_cmd", "exit"]],
["f() { local REGEX='[0-9]+${FOO}'; return $?; }", ["local", "return"]],
] as const)("scans assignment-only boundaries without evaluating their effects: %s", (command, names) => {
const result = ShellScan.scan(command)
expect(result.kind).toBe("scanned")
@@ -236,6 +234,8 @@ describe("ShellScan", () => {
"{fd}>/tmp/log touch /tmp/victim",
"time touch /tmp/victim",
"printf '%s' \"$(printf safe ${x%)}; touch /tmp/victim)\"",
"s=abc; x='a[$(touch /tmp/victim)0]'; printf '%s' \"${s:x}\"",
"ref='x[$(touch /tmp/victim)0]'; printf '%s' \"${!ref}\"",
"if true; then echo safe; fi > /tmp/victim",
"if true; then :; 'if' victim; fi",
])("scans Bash lexical forms without interpreting shell values: %s", (command) => {
+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 -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)
},
})
}
@@ -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,
-16
View File
@@ -1,16 +0,0 @@
# `@opencode/plugin`
Authoring interfaces and runtime loader support for OpenCode V2 plugins:
- `@opencode/plugin` — [Promise plugin API](./src/README.md)
- `@opencode/plugin/effect` — [Effect plugin API](./src/effect/README.md)
- `@opencode/plugin/rpc` — portable RPC contract definitions
- `@opencode/plugin/tui` — terminal UI plugin API
## Packaging And Runtime `effect`
When the OpenCode CLI loads server or TUI plugins, it resolves imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` entrypoints (including imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances so fibers, loggers, and `Schema` parsers share one copy.
- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking and testing) rather than a bundled runtime dependency.
- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. Two copies of `effect` do not share fiber, logger, or `Schema` internals.
- Plugins and their `node_modules` dependencies always receive OpenCode's host `effect` instance. Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another `effect` major (such as Effect 3) are not supported.
+2 -2
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { describe, it } from "node:test"
@@ -11,7 +11,7 @@ const source = 'throw new Error("Plugin code must not run during resolution")'
const name = "@fixture/plugin"
async function fixture(files: Record<string, string>, installed = false) {
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-")))
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
const directory = installed ? path.join(root, "node_modules", name) : root
await Promise.all(
Object.entries(files).map(async ([file, content]) => {
+42 -27
View File
@@ -1094,6 +1094,7 @@ body {
[data-page="stats"] [data-component="top-models-chart"] {
--top-models-bar-gap: 12px;
--top-models-dot-size: 6px;
--top-models-dot-offset: 2px;
position: relative;
display: grid;
grid-template-rows: 34px minmax(0, 1fr);
@@ -1223,31 +1224,38 @@ body {
padding-inline: calc(var(--top-models-bar-gap) / 2);
outline: none;
cursor: pointer;
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
--top-models-bar-inset: calc((100% - 6px) / 6);
}
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
[data-page="stats"] [data-slot="top-models-bar"]::before {
position: absolute;
inset: 0 var(--top-models-bar-inset);
top: 0;
bottom: 0;
left: 50%;
width: calc(100% - var(--top-models-bar-gap));
transform: translateX(-50%);
content: "";
background: var(--stats-dot);
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
mask-position: left top;
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: var(--top-models-dot-offset) top;
mask-repeat: repeat;
mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
-webkit-mask-position: left top;
mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: var(--top-models-dot-offset) top;
-webkit-mask-repeat: repeat;
-webkit-mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
-webkit-mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
}
@supports (width: round(down, 100%, 1px)) {
[data-page="stats"] [data-slot="top-models-bar"]::before {
width: round(down, calc(100% - var(--top-models-bar-gap)), var(--top-models-dot-size));
}
}
[data-page="stats"] [data-slot="top-models-stack"] {
position: absolute;
right: var(--top-models-bar-inset);
right: calc(var(--top-models-bar-gap) / 2);
bottom: 0;
left: var(--top-models-bar-inset);
left: calc(var(--top-models-bar-gap) / 2);
z-index: 1;
display: grid;
height: var(--top-models-bar-height);
@@ -4537,6 +4545,7 @@ body {
[data-page="stats"] [data-component="model-usage-chart"] {
--model-usage-bar-gap: 6px;
--model-usage-dot-size: 6px;
--model-usage-dot-offset: 2px;
position: relative;
display: grid;
grid-template-rows: 40px minmax(0, 1fr);
@@ -4594,31 +4603,38 @@ body {
padding-inline: calc(var(--model-usage-bar-gap) / 2);
outline: none;
cursor: pointer;
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
--model-usage-bar-inset: calc((100% - 6px) / 6);
}
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
[data-page="stats"] [data-slot="model-usage-column"]::before {
position: absolute;
inset: 0 var(--model-usage-bar-inset);
top: 0;
bottom: 0;
left: 50%;
width: calc(100% - var(--model-usage-bar-gap));
transform: translateX(-50%);
content: "";
background: var(--stats-dot);
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
mask-position: left top;
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: var(--model-usage-dot-offset) top;
mask-repeat: repeat;
mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
-webkit-mask-position: left top;
mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: var(--model-usage-dot-offset) top;
-webkit-mask-repeat: repeat;
-webkit-mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
-webkit-mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
}
@supports (width: round(down, 100%, 1px)) {
[data-page="stats"] [data-slot="model-usage-column"]::before {
width: round(down, calc(100% - var(--model-usage-bar-gap)), var(--model-usage-dot-size));
}
}
[data-page="stats"] [data-slot="model-usage-bar"] {
position: absolute;
right: var(--model-usage-bar-inset);
right: calc(var(--model-usage-bar-gap) / 2);
bottom: 0;
left: var(--model-usage-bar-inset);
left: calc(var(--model-usage-bar-gap) / 2);
z-index: 1;
height: var(--model-usage-fill);
min-height: 0;
@@ -8180,8 +8196,7 @@ body {
}
[data-page="stats"] [data-component="model-usage-chart"] {
/* 18px puts three 2px dot columns 6px apart, matching the 6px bar gap. */
--model-usage-mobile-bar-width: 18px;
--model-usage-mobile-bar-width: 16px;
--model-usage-mobile-edge-space: 48px;
--model-usage-mobile-track-width: calc(
var(--model-usage-count) * (var(--model-usage-mobile-bar-width) + var(--model-usage-bar-gap)) +
@@ -8222,7 +8237,7 @@ body {
}
[data-page="stats"] [data-slot="model-usage-column"]::before {
inset: 0;
width: 100%;
}
[data-page="stats"] [data-slot="model-usage-bar"] {
@@ -1,10 +1,8 @@
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
import { ensurePluginRuntime } from "../../../cli/src/plugin-runtime"
ensureRuntimePluginSupport({
additional: {
...ensurePluginRuntime(),
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
},
})
@@ -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 },
-121
View File
@@ -7,7 +7,6 @@ import "../src/plugin/runtime-plugin-support.bun"
import { createPluginSources } from "../src/plugin/source"
import { createSourceWatcher } from "../src/plugin/watch"
import { createSignal } from "solid-js"
import { Effect, Option, Schema } from "effect"
import { Plugin } from "@opencode/plugin/tui"
import { tmpdir } from "./fixture/fixture"
@@ -139,126 +138,6 @@ test("shared runtime and ordinary package identities survive plugin generations"
}
})
test("TUI plugins importing @opencode/plugin/tui and solid-js alongside effect resolve effect and effect/* to the host copy", async () => {
await using sources = await fixture()
const entry = new URL("tui.ts", sources.url)
await Bun.write(
new URL("node_modules/effect/package.json", sources.url),
'{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./RemovedSubpath":"./RemovedSubpath.js","./package.json":"./package.json"}}',
)
await Bun.write(
new URL("node_modules/effect/index.js", sources.url),
"export const Effect = { foreign: true }; export const Schema = { foreign: true }",
)
await Bun.write(new URL("node_modules/effect/Option.js", sources.url), "export const some = () => null")
await Bun.write(new URL("node_modules/effect/RemovedSubpath.js", sources.url), "export const removed = true")
await Bun.write(
new URL("node_modules/effect-helper/package.json", sources.url),
'{"name":"effect-helper","type":"module","exports":{".":"./index.js"}}',
)
await Bun.write(
new URL("node_modules/effect-helper/index.js", sources.url),
'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const helper = { Effect, Schema, some }',
)
await Bun.write(
entry,
`import { createSignal } from "solid-js"
import { Plugin } from "@opencode/plugin/tui"
import { Effect, Schema } from "effect"
import { some } from "effect/Option"
import pkg from "effect/package.json" with { type: "json" }
import { helper } from "effect-helper"
export const plugin = { createSignal, Plugin, Effect, Schema, some, pkgName: pkg.name }
export { helper }`,
)
const loaded = (await sources.read(entry.href)).module as {
plugin: Record<string, unknown>
helper: Record<string, unknown>
}
expect(loaded.plugin.createSignal).toBe(createSignal)
expect(loaded.plugin.Plugin).toBe(Plugin)
expect(loaded.plugin.Effect).toBe(Effect)
expect(loaded.plugin.Schema).toBe(Schema)
expect(loaded.plugin.some).toBe(Option.some)
expect(loaded.plugin.pkgName).toBe("effect")
expect(loaded.helper.Effect).toBe(Effect)
expect(loaded.helper.Schema).toBe(Schema)
expect(loaded.helper.some).toBe(Option.some)
const badEntry = new URL("bad-tui.ts", sources.url)
await Bun.write(
badEntry,
'import { Plugin } from "@opencode/plugin/tui"; import { removed } from "effect/RemovedSubpath"; export default { Plugin, removed }',
)
await expect(sources.read(badEntry.href)).rejects.toThrow("effect/RemovedSubpath.js")
})
test.each(["cli-first", "opentui-first"] as const)(
"resolves host Effect in TUI plugins under %s registration order",
async (order) => {
await using dir = await tmpdir()
await Bun.write(
path.join(dir.path, "node_modules/effect/package.json"),
'{"name":"effect","type":"module","exports":{".":"./index.js","./Brand":"./Brand.js"}}',
)
await Bun.write(path.join(dir.path, "node_modules/effect/index.js"), "export const Effect = { foreign: true }")
await Bun.write(path.join(dir.path, "node_modules/effect/Brand.js"), "export const nominal = () => null")
const pluginEntry = path.join(dir.path, "tui.ts")
await Bun.write(
pluginEntry,
`import { Plugin } from "@opencode/plugin/tui"
import { createSignal } from "solid-js"
import { Effect } from "effect"
import { nominal } from "effect/Brand"
export const captured = { Plugin, createSignal, Effect, nominal }`,
)
const probe = path.join(dir.path, "probe.ts")
await Bun.write(
probe,
`import assert from "node:assert/strict"
if (${JSON.stringify(order)} === "cli-first") {
const { ensurePluginRuntime } = await import(${JSON.stringify(fileURLToPath(new URL("../../cli/src/plugin-runtime.ts", import.meta.url)))})
ensurePluginRuntime()
}
await import(${JSON.stringify(fileURLToPath(new URL("../src/plugin/runtime-plugin-support.bun.ts", import.meta.url)))})
const { createPluginSources } = await import(${JSON.stringify(fileURLToPath(new URL("../src/plugin/source.ts", import.meta.url)))})
const { Effect, Brand } = await import("effect")
const { Plugin } = await import("@opencode/plugin/tui")
const { createSignal } = await import(${JSON.stringify(Bun.resolveSync("solid-js", import.meta.dir))})
const sources = createPluginSources(async () => {})
try {
const loaded = (await sources.read(${JSON.stringify(pathToFileURL(pluginEntry).href)})).module as {
captured: Record<string, unknown>
}
assert.equal(loaded.captured.Plugin, Plugin)
assert.equal(loaded.captured.createSignal, createSignal)
assert.equal(loaded.captured.Effect, Effect)
assert.equal(loaded.captured.nominal, Brand.nominal)
console.log(${JSON.stringify(`order passed: ${order}`)})
} finally {
sources.dispose()
}`,
)
const child = Bun.spawn([process.execPath, probe], {
cwd: fileURLToPath(new URL("..", import.meta.url)),
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exit] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
expect({ stdout: stdout.trim(), stderr, exit }).toEqual({
stdout: `order passed: ${order}`,
stderr: "",
exit: 0,
})
},
)
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
await using sources = await fixture()
const entry = new URL("tui.ts", sources.url)
+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
+1 -2
View File
@@ -91,8 +91,7 @@ if (Script.release && !Script.preview) {
await $`git checkout -B v2 origin/v2`
await prepareReleaseFiles()
if ((await $`git diff --quiet`.nothrow()).exitCode !== 0) {
// The release already published this code; a push-triggered dev publish of a version bump is wasted work.
await $`git commit -am ${`sync release versions for ${tag} [skip ci]`}`
await $`git commit -am "sync release versions for ${tag}"`
await $`git push origin HEAD:v2 --no-verify`
}
}
@@ -3,14 +3,11 @@ title: "Effect"
---
`@opencode/plugin/effect` is the Effect-native version of the OpenCode plugin API. Its context operations return
Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`.
OpenCode provides `effect` (including exported `effect/*` subpaths) and `@opencode/plugin` at runtime when loading
plugins and their `node_modules` dependencies. Install them as peer/dev dependencies for local type-checking and testing
rather than bundling them into your plugin.
Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`. Install `effect` with the
plugin package.
```sh
bun add --peer @opencode/plugin effect
bun add @opencode/plugin effect
```
Export an Effect plugin from `.opencode/plugins/` to load it automatically.
@@ -1503,7 +1500,7 @@ type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Regi
## Publish
A package plugin uses the same default export as a local Effect plugin. Export the Effect implementation from the main
entrypoint and declare `effect` as a `peerDependency`.
entrypoint and declare both runtime dependencies.
```json title="package.json"
{
@@ -1514,24 +1511,12 @@ entrypoint and declare `effect` as a `peerDependency`.
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "latest"
},
"peerDependencies": {
"effect": ">=4.0.0-rc.112"
"@opencode/plugin": "latest",
"effect": "4.0.0-rc.111"
}
}
```
OpenCode redirects imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` (including imports from
dependencies in the plugin's `node_modules`) to the host's runtime instances so fibers, loggers, and `Schema` parsers
share one runtime copy.
- Do not bundle `effect` into the published plugin artifact. If you build with a bundler, mark `effect` and `effect/*`
external.
- Plugins and their `node_modules` dependencies always execute against the host OpenCode release's `effect` instance.
Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another
`effect` major (such as Effect 3) also receive the host's `effect` and are not supported.
The package entrypoint exports `Plugin.define` with an `effect` function.
```ts title="src/index.ts"