Compare commits

...
Author SHA1 Message Date
Dax Raad ab3566ab82 feat(server): expose server info endpoint 2026-09-16 21:43:58 -04:00
Dax Raad b642c1d9ea docs: add CLI settings reference 2026-09-16 21:18:35 -04:00
David HillandLukeParkerDev c8cc984aa0 feat(app): improve project settings and actions (#49423)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-17 00:39:21 +00:00
Luke Parker 5a9448acbb fix(app): retry transport errors wrapped by the client (#49426) 2026-09-17 10:34:52 +10:00
David Hill f6604cd367 feat(desktop): add animated first-launch loading screen (#49408) 2026-09-17 10:02:09 +10:00
Luke Parker acfacede2d fix(app): preserve native clipboard paste (#49424) 2026-09-17 09:47:00 +10:00
Aiden Cline 4a27842fe6 fix(ai): classify gateway account limits as quota and keep 4xx non-retryable (#49195) 2026-09-16 16:14:09 -05:00
Aiden Cline d797722187 feat(codemode): carry cause and own data across the error boundary (#49390) 2026-09-16 15:58:05 -05:00
Dax 7390832f13 feat(tui): reload all locations
Add a global location reload endpoint, shared shutdown lifecycle, client resynchronization, and the TUI /reload command.\n\nfrom OpenCode
2026-09-16 16:47:44 -04:00
Kit Langton 7689c3654e fix(tui): hide error hint when MCP Enter starts sign-in (#49403) 2026-09-16 19:33:08 +00:00
Dax 7df0935ada fix(tui): apply model selection on blank submit (#49374)
Apply the selected model on blank Enter and simplify shared prompt submission setup and recovery.\n\nfrom OpenCode
2026-09-16 19:32:45 +00:00
Dax bcd43760df feat(cli): support inline config content (#49399) 2026-09-16 19:18:55 +00:00
Aiden Cline 9073c522ef feat(plugin): add experimental WebSocket send and receive hooks (#49136) 2026-09-16 14:13:33 -05:00
opencode-agent[bot]andrekram1-node 04c296310e fix(core): skip session warming for subagents (#49387)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-16 13:58:40 -05:00
134 changed files with 2514 additions and 778 deletions
+2 -2
View File
@@ -54,7 +54,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
### [x] `GET /api/health` and `GET /api/server`
- **Decision:** Merge and rename
- **Replacement:** `GET /api/status` with operation ID `server.status`.
- **Replacement:** `GET /api/info` with operation ID `server.info`.
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
### [x] `GET /api/project/current`
@@ -74,7 +74,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
| [x] 001002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
+21 -6
View File
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
"insufficient_quota",
"usage_not_included",
"billing_error",
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
// as a `[code]` label at the start of the rewritten message.
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
((input.status === undefined || input.status < 400) &&
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
)
return new ProviderInternalError({
...details,
+3 -3
View File
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
it.effect("does not let server codes override a 4xx rejection", () =>
Effect.gen(function* () {
const classify = (body: string) =>
Effect.gen(function* () {
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
}),
)
+47 -3
View File
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(Array(5).fill("InvalidRequest"))
})
test("classifies 402 as exhausted quota", () => {
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
})
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
const substituted = (message: string) => ({
error: { type: "server_error", message: `Upstream request failed: ${message}` },
})
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
[402, substituted("Insufficient account funds")],
[402, substituted("Account invoice is overdue")],
[429, substituted("Account budget exceeded")],
]
expect(
cases.map(
([status, body]) =>
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(Array(6).fill("InvalidRequest"))
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
type: "error",
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
}
expect(
[openai, anthropic].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["InvalidRequest", "InvalidRequest"])
// Without a contradicting status the same codes still mark provider trouble.
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
"ProviderInternal",
)
expect(
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
).toBe("ProviderInternal")
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -62,7 +62,8 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/info")
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
@@ -16,8 +16,13 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/status") {
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/info") {
return json(route, {
version: "2.0.0",
pid: 1,
urls: [url.origin],
paths: { tmp: "/tmp/opencode" },
})
}
return json(route, {})
})
@@ -138,7 +138,14 @@ test("MCP authentication starts before a slow resource catalog finishes", async
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) =>
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
route.fulfill({
json: {
version: "2.0.0",
pid: 1,
urls: ["http://secondary.test"],
paths: { tmp: "/tmp/opencode" },
},
}),
)
await page.addInitScript(
({ directory, server }) => {
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/api/status")
const response = await fetch("/api/info")
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -172,64 +172,6 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async
await expect(result).toBeFocused()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
@@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/status")
if (path === "/api/info")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
@@ -334,8 +334,13 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
const api = await page.goto(`${site.url}/api/info`)
expect(await api?.json()).toEqual({
version: "test",
pid: 1,
urls: ["http://localhost"],
paths: { tmp: "/tmp/opencode" },
})
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
+1 -1
View File
@@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
+7 -1
View File
@@ -219,7 +219,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
}),
)
.handleAll({
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
info: () =>
Effect.succeed({
version: "2.0.0",
pid: 1,
urls: config.server ? [config.server] : [],
paths: { tmp: "/tmp/opencode" },
}),
config: () => Effect.succeed(configEntries),
reference: () =>
Effect.succeed({
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { shouldHandlePasteAsAttachment } from "./interaction"
test("leaves paste to the browser when web clipboard data is unavailable", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), false)).toBe(false)
})
test("uses native image reading only when the clipboard has no text", () => {
expect(shouldHandlePasteAsAttachment(clipboard(), true)).toBe(true)
expect(shouldHandlePasteAsAttachment(clipboard(["text/plain"]), true)).toBe(false)
})
test("handles clipboard files as attachments", () => {
expect(shouldHandlePasteAsAttachment(clipboard([], [{ kind: "file" }]), false)).toBe(true)
})
function clipboard(types: string[] = [], items: Array<{ kind: string }> = []) {
return { types, items } as unknown as DataTransfer
}
@@ -384,20 +384,20 @@ export function createComposerEditor(input: {
},
onPaste(event: ClipboardEvent) {
const clipboard = event.clipboardData
if (
attachments &&
(Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain"))
) {
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
void attachments.handlePaste(event)
return
}
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
if (!text) return
event.preventDefault()
// insertText emits input events per line, repeatedly parsing and saving the draft.
// Escaped HTML inserts multiline text once and preserves native selection and undo.
const multiline = text.includes("\n")
const value = multiline ? text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;") : text
const normalized = text.replace(/\r\n?/g, "\n")
const multiline = normalized.includes("\n")
const value = multiline
? normalized.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
: normalized
if (
typeof document.execCommand === "function" &&
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
@@ -408,13 +408,13 @@ export function createComposerEditor(input: {
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
const range = selection.getRangeAt(0)
range.deleteContents()
const node = document.createTextNode(text)
const node = document.createTextNode(normalized)
range.insertNode(node)
range.setStartAfter(node)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: normalized }))
},
onDragEnter(event: DragEvent) {
event.preventDefault()
@@ -450,6 +450,12 @@ export function createComposerEditor(input: {
export type ComposerEditorModel = ReturnType<typeof createComposerEditor>
export function shouldHandlePasteAsAttachment(clipboard: DataTransfer | null, readClipboardImage: boolean) {
if (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file")) return true
if (Array.from(clipboard?.types ?? []).some((type) => type.startsWith("text/"))) return false
return readClipboardImage
}
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
if (inHistory) return position === 0 || position === text.length
+1
View File
@@ -15,6 +15,7 @@ export type {
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useGlobal } from "./runtime/server/runtime"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
+10 -5
View File
@@ -90,7 +90,12 @@ test("rotates HTTP and PTY clients together", async () => {
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
return Response.json({
version: "2.0.0-test",
pid: 1,
urls: [request.url],
paths: { tmp: "/tmp/opencode" },
})
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", password: "first" },
@@ -98,23 +103,23 @@ test("rotates HTTP and PTY clients together", async () => {
})
const initialPty = transport.pty
await transport.api.server.status()
await transport.api.server.info()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
password: "second",
})
await transport.api.server.status()
await transport.api.server.info()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/status",
url: "http://127.0.0.1:4100/api/info",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/status",
url: "http://127.0.0.1:4200/api/info",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
@@ -5,6 +5,7 @@ import { createStore } from "solid-js/store"
import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { createServerTransport } from "@/runtime/server/client"
import type { ServerSync } from "@/runtime/server/sync"
import { worktreeInventoryKey } from "@/workspaces/inventory"
@@ -64,6 +65,44 @@ test("bootstraps projects through the native store setter and preserves subseque
}
})
// Chromium aborts in-flight loopback requests with ERR_NETWORK_CHANGED when Windows reconfigures an
// adapter; the client wraps that as ClientError("Transport"), which the bootstrap retry must see through.
test("recovers project metadata after the connection to the server is dropped", async () => {
const body = JSON.stringify([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
let dropped = 0
const requests: string[] = []
const server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
if (dropped >= 2) return
dropped += 1
socket.terminate()
},
data(socket, chunk) {
requests.push(String(chunk).split(" ")[0] ?? "")
socket.end(
`HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\naccess-control-allow-origin: *\r\nconnection: close\r\n\r\n${body}`,
)
},
},
})
const transport = createServerTransport({ http: { url: `http://127.0.0.1:${server.port}` } })
try {
const result = await new QueryClient({ defaultOptions: { queries: { retry: false } } }).fetchQuery(
loadProjectsQuery(ServerScope.local, transport.api.project),
)
expect(dropped).toBe(2)
// happy-dom's fetch adds a CORS preflight; only the GET is the retried API call.
expect(requests.filter((method) => method === "GET")).toHaveLength(1)
expect(result).toMatchObject([{ id: "project", worktree: "/repo" }])
} finally {
server.stop(true)
}
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as ServerApi["location"]
+29 -15
View File
@@ -17,7 +17,12 @@ describe("checkServerHealth", () => {
const headers: Array<string | null> = []
const fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("authorization"))
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: [server.url],
paths: { tmp: "/tmp/opencode" },
})
}) as typeof globalThis.fetch
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
@@ -28,16 +33,19 @@ describe("checkServerHealth", () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/status")
expect(request?.pathname).toBe("/api/info")
})
test("allows slow servers thirty seconds by default", async () => {
@@ -52,7 +60,7 @@ describe("checkServerHealth", () => {
})
const fetch = (async () =>
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
@@ -111,10 +119,13 @@ describe("checkServerHealth", () => {
let signal: AbortSignal | undefined
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
signal = abortFromInput(input, init)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
@@ -130,10 +141,13 @@ describe("checkServerHealth", () => {
const fetch = (async () => {
count += 1
if (count < 3) throw new TypeError("network")
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, {
+1 -1
View File
@@ -95,7 +95,7 @@ export async function checkServerHealth(
fetch,
headers,
})
.server.status({ signal })
.server.info({ signal })
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
.catch((error) => ({ error }))
if ("data" in current) return current.data
+4 -9
View File
@@ -8,11 +8,8 @@ import { ServerHttp, ServerHttpBase, ServerKey, serverState } from "./persistenc
import type { SshItem } from "@/servers/ssh/types"
type ServerState = ReturnType<typeof serverState>["current"]["Type"]
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
// Retain closed paths until reopened so settings can exclude them from the server inventory.
// The Home page independently limits the visible recently closed entries.
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
@@ -51,6 +48,7 @@ export function createServerProjects(input: {
}
return {
list: current,
closed: currentClosed,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
@@ -72,10 +70,7 @@ export function createServerProjects(input: {
close(directory: string) {
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)]
setStore("recentlyClosed", input.scope(), closed)
},
expand(directory: string) {
@@ -195,7 +195,7 @@ describe("createRequestQueue", () => {
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/status").catch(() => undefined)
input.queue.fetch("http://server/api/info").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
@@ -210,7 +210,7 @@ describe("createRequestQueue", () => {
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
{ method: "GET", url: "http://server/api/info", ms: 2_000 },
],
},
},
+6 -1
View File
@@ -146,7 +146,11 @@ function createServerController(
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
const base = {
...metadata,
...(!metadata || metadata.id === "global" ? childStore.projectMeta : undefined),
...project,
}
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
@@ -174,6 +178,7 @@ function createServerController(
projects: {
...projects,
list: projectsList,
resolve: enrich,
recentlyClosed: recentlyClosedList,
},
notification,
+14 -6
View File
@@ -6,12 +6,19 @@ import { useLanguage } from "@/runtime/i18n/language"
export function SettingsSearchEmpty(props: { query: string }) {
const language = useLanguage()
const [state, setState] = createStore({ query: props.query })
let quoted: HTMLSpanElement | undefined
let container: HTMLDivElement | undefined
let measure: HTMLSpanElement | undefined
const text = (query: string) => language.t("settings.search.empty.query", { query })
const update = () => {
if (!quoted || !measure) return
const width = quoted.getBoundingClientRect().width
if (!container || !measure) return
const style = getComputedStyle(container)
measure.textContent = language.t("settings.search.empty", { query: "" })
// Measure the available query space independently of its current truncated text.
const width =
container.clientWidth -
parseFloat(style.paddingInlineStart) -
parseFloat(style.paddingInlineEnd) -
measure.getBoundingClientRect().width
const fits = (query: string) => {
measure!.textContent = text(query)
return measure!.getBoundingClientRect().width <= width
@@ -40,21 +47,22 @@ export function SettingsSearchEmpty(props: { query: string }) {
createEffect(update)
onMount(() => {
if (!quoted || !measure) return
if (!container || !measure) return
// The measuring text also observes font changes that do not resize the available space.
createResizeObserver([quoted, measure], update)
createResizeObserver([container, measure], update)
})
return (
<>
<div
ref={container}
class="settings-search-empty"
role="status"
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
>
{language.rich("settings.search.empty", {
query: (
<span ref={quoted} class="settings-search-empty-quoted">
<span class="settings-search-empty-quoted">
<bdi dir="auto">{text(state.query)}</bdi>
</span>
),
+8 -6
View File
@@ -15,11 +15,12 @@
visibility: hidden;
}
.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
color: var(--v2-icon-icon-faint);
cursor: default;
}
.settings-search
:is(.settings-search, .settings-projects-search)
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
background: transparent;
color: var(--v2-icon-icon-base);
@@ -37,19 +38,20 @@
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] {
--mask-start: 16px;
}
.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] {
--mask-end: 16px;
}
.settings-search [data-slot="text-input-v2-input"]:dir(rtl) {
:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) {
--mask-direction: to left;
}
.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
:is(.settings-search, .settings-projects-search)
[data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
@@ -7,14 +7,18 @@ import { sshName, type SshItem } from "@/servers/ssh/types"
import type { ServerCtx } from "@/runtime/server/runtime"
import { pathKey } from "@/workspaces/path-key"
export function settingsProjects(context: ServerCtx) {
export function settingsProjects(context: {
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
}) {
const tracked = context.projects.list()
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
const closed = new Set(context.projects.closed().map(pathKey))
return [
...tracked,
...context.sync.data.project
.filter((project) => !paths.has(pathKey(project.worktree)))
.map((project) => ({ ...project, expanded: false })),
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
]
}
+72
View File
@@ -803,6 +803,78 @@
padding-inline-end: 28px;
}
.settings-projects-search [data-component="text-input-v2"] {
height: 36px;
background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
}
.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) {
background: var(--v2-background-bg-layer-02);
}
.settings-projects-search [data-slot="text-input-v2-input"] {
padding-inline-end: 0;
}
.settings-projects-search [data-slot="text-input-v2-leading-icon"] {
padding-inline-start: 16px;
}
.settings-projects-search [data-slot="text-input-v2-value"] {
gap: 12px;
}
.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
margin-inline-end: -4px;
}
.settings-projects-empty {
width: 60%;
margin-inline: auto;
}
.settings-projects-empty .settings-search-empty {
justify-content: center;
padding-inline: 0;
}
.settings-projects-empty .settings-search-empty-quoted {
flex: 0 1 auto;
}
.settings-project-row {
position: relative;
margin-inline: 1px;
border-radius: 8px;
background: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
transition: background-color 120ms;
}
.settings-project-row:hover {
background: var(--v2-background-bg-layer-02);
}
.settings-project-row-content {
display: flex;
width: 100%;
min-height: 73px;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px;
border-radius: inherit;
text-align: start;
}
.settings-project-row-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
.settings-tab-search-clear {
position: absolute;
top: 50%;
-3
View File
@@ -294,8 +294,6 @@ function RootSettings() {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
autofocus={!surface.search.state.selected}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
@@ -378,7 +376,6 @@ function ServerSettings(props: { entry: SettingsServer }) {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
active={surface.view().tab === "projects"}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
@@ -0,0 +1,230 @@
import { Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { InlineInput } from "@opencode/ui/inline-input"
import { Menu } from "@opencode/ui/menu"
import { getFilename } from "@opencode/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { displayName, errorMessage } from "@/shell/layout/helpers"
import { fileManagerApp } from "@/home/projects/file-manager"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { showToast } from "@/shell/notifications/toast"
import type { LocalProject } from "@/shell/state/layout"
export function SettingsProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
onOpen: (project: LocalProject) => void
}) {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const [store, setStore] = createStore({
menu: undefined as { x: number; y: number } | undefined,
editor: undefined as { draft: string; saving: boolean } | undefined,
})
let row: HTMLDivElement | undefined
let button: HTMLButtonElement | undefined
let input: HTMLInputElement | undefined
let outside = false
const openMenu = (x: number, y: number) => {
if (!row) return
const bounds = row.getBoundingClientRect()
setStore("menu", { x: x - bounds.left, y: y - bounds.top })
}
const openEditor = () => {
setStore("editor", { draft: displayName(props.project), saving: false })
requestAnimationFrame(() => {
input?.focus()
input?.select()
})
}
const closeEditor = () => {
if (store.editor?.saving) return
setStore("editor", undefined)
}
const saveEditor = async () => {
if (!store.editor || store.editor.saving) return
const name = store.editor.draft.trim()
if (!name || name === displayName(props.project)) {
closeEditor()
requestAnimationFrame(() => button?.focus())
return
}
setStore("editor", "saving", true)
const context = global.ensureServerCtx(props.server)
const value = name === getFilename(props.project.worktree) ? "" : name
const saved = await (props.project.id && props.project.id !== "global"
? context.sdk.api.project
.update({ projectID: props.project.id, name: value })
.then((project) => context.sync.project.update(project))
: Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value }))
)
.then(() => true)
.catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
const restore = document.activeElement === document.body || document.activeElement === input
if (saved) setStore("editor", undefined)
if (!saved) setStore("editor", "saving", false)
if (!restore) return
requestAnimationFrame(() => (saved ? button : input)?.focus())
}
return (
<div
ref={row}
data-component="settings-project-row"
class="settings-project-row group"
onContextMenu={(event) => {
if (store.editor) return
event.preventDefault()
openMenu(event.clientX, event.clientY)
}}
>
<Show
when={!store.editor}
fallback={
<div class="settings-project-row-content">
<ProjectRowContent project={props.project}>
<InlineInput
ref={input}
aria-label={language.t("common.rename")}
dir="auto"
value={store.editor?.draft ?? ""}
disabled={store.editor?.saving}
class="settings-project-row-name w-full outline-none"
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => button?.focus())
}}
onBlur={closeEditor}
/>
</ProjectRowContent>
</div>
}
>
<button
ref={button}
type="button"
aria-label={displayName(props.project)}
aria-haspopup="menu"
aria-expanded={!!store.menu}
class="settings-project-row-content"
onClick={() => props.onOpen(props.project)}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(bounds.left + 12, bounds.bottom)
}}
>
<ProjectRowContent project={props.project}>
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
</ProjectRowContent>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!store.menu}
onOpenChange={(open) => {
if (!open) setStore("menu", undefined)
}}
>
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
/>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
outside = true
}}
onCloseAutoFocus={(event) => {
event.preventDefault()
const restore = !outside && !store.editor
outside = false
if (restore) requestAnimationFrame(() => button?.focus())
}}
>
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
<Show
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
>
<Menu.Item
onSelect={() => {
if (!platform.openPath) return
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}}
>
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item
onSelect={() => {
const next = row?.nextElementSibling ?? row?.previousElementSibling
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
requestAnimationFrame(() => next?.querySelector("button")?.focus())
}}
>
{language.t("common.close")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)
}
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
return (
<span class="flex items-start gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
{props.children}
<bdi
dir="ltr"
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
title={props.project.worktree}
>
{props.project.worktree}
</bdi>
</span>
</span>
)
}
@@ -1,45 +1,43 @@
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
import { Show, createEffect, createMemo, on, type Component } from "solid-js"
import { Key } from "@solid-primitives/keyed"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import type { LocalProject } from "@/shell/state/layout"
import { SettingsSearchEmpty } from "../search-empty"
import { settingsProjects } from "../servers/inventory"
import { SettingsProjectRow } from "./project-row"
import "@/settings/search.css"
import "@/settings/settings.css"
export const SettingsProjects: Component<{
server: ServerConnection.Any
active?: boolean
autofocus?: boolean
onOpenProject: (project: LocalProject) => void
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const [store, setStore] = createStore({ filter: "" })
const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } })
let search: HTMLInputElement | undefined
const updateOverflow = () => {
if (!search) return
const offset = Math.abs(search.scrollLeft)
setStore("overflow", {
start: offset > 1,
end: search.scrollWidth - search.clientWidth - offset > 1,
})
}
createEffect(on(() => store.filter, updateOverflow))
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
const searchable = createMemo(() => projects().length > 7)
const filtered = createMemo(() => {
const query = searchable() ? store.filter.trim().toLowerCase() : ""
return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
})
createEffect(
on(
() => (props.active ?? true) && searchable(),
(active) => {
if (!active) return
const frame = requestAnimationFrame(() => {
if (props.active !== false && props.autofocus !== false && search?.isConnected)
search.focus({ preventScroll: true })
})
onCleanup(() => cancelAnimationFrame(frame))
},
),
)
createEffect(() => {
if (!searchable()) setStore("filter", "")
})
@@ -54,16 +52,24 @@ export const SettingsProjects: Component<{
</div>
</div>
<Show when={searchable()}>
<div class="settings-tab-search">
<div class="settings-tab-search settings-projects-search">
<TextInput
ref={search}
ref={(element) => {
search = element
createResizeObserver(element, updateOverflow)
}}
type="search"
appearance="base"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
value={store.filter}
data-overflow-start={store.overflow.start}
data-overflow-end={store.overflow.end}
onScroll={updateOverflow}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.projects.search.placeholder")}
aria-label={language.t("settings.projects.search.placeholder")}
showClearButton={!!store.filter}
clearIcon="circle-xmark"
onClearClick={() => {
setStore("filter", "")
search?.focus({ preventScroll: true })
@@ -81,32 +87,26 @@ export const SettingsProjects: Component<{
<Show
when={filtered().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
<Show
when={store.filter.trim()}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="settings-projects-empty">
<SettingsSearchEmpty query={store.filter} />
</div>
</Show>
}
>
<div class="flex w-full flex-col gap-2">
<For each={filtered()}>
<Key each={filtered()} by="worktree">
{(project) => (
<button
type="button"
aria-label={displayName(project)}
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
onClick={() => props.onOpenProject(project)}
>
<span class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={project} class="shrink-0" />
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
</span>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />
)}
</For>
</Key>
</div>
</Show>
</div>
+9 -9
View File
@@ -2,7 +2,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode/client/effect/service"
import { ServerStatus } from "@opencode/protocol/groups/server"
import { ServerInfo } from "@opencode/protocol/groups/server"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
@@ -42,12 +42,12 @@ try {
const credential = btoa(`opencode:${info.password}`)
const headers = { authorization: "Basic " + credential }
const token = encodeURIComponent(credential)
const status = await waitForReady(info.url, headers)
if (status.pid !== info.pid) throw new Error("Status process does not match registration")
const tokenStatus = await fetch(new URL(`/api/status?auth_token=${token}`, info.url), {
const serverInfo = await waitForReady(info.url, headers)
if (serverInfo.pid !== info.pid) throw new Error("Server info does not match registration")
const tokenInfo = await fetch(new URL(`/api/info?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
if (tokenStatus.status !== 200) throw new Error("Compiled service rejected query authentication")
if (tokenInfo.status !== 200) throw new Error("Compiled service rejected query authentication")
const tokenOpenApi = await fetch(new URL(`/openapi.json?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -57,10 +57,10 @@ try {
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
const unauthorizedStatus = await fetch(new URL("/api/status", info.url), {
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedStatus.status !== 401) throw new Error("Compiled service exposed status without authentication")
if (unauthorizedInfo.status !== 401) throw new Error("Compiled service exposed info without authentication")
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -128,11 +128,11 @@ async function waitForRegistration() {
async function waitForReady(url: string, headers: HeadersInit) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const response = await fetch(new URL("/api/status", url), {
const response = await fetch(new URL("/api/info", url), {
headers,
signal: AbortSignal.timeout(1_000),
}).catch(() => undefined)
if (response?.ok) return Schema.decodeUnknownPromise(ServerStatus)(await response.json())
if (response?.ok) return Schema.decodeUnknownPromise(ServerInfo)(await response.json())
await Bun.sleep(25)
}
throw new Error("Compiled service did not become ready")
+1 -1
View File
@@ -15,7 +15,7 @@ export default Runtime.handler(
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.status(),
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
process.stdout.write(
+44 -9
View File
@@ -20,7 +20,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
const decode = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const empty: Info = {}
export const layer = Layer.effect(
@@ -29,14 +29,14 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const content = process.env.OPENCODE_CLI_CONFIG_CONTENT
? Option.getOrUndefined(decode(parseRecord(process.env.OPENCODE_CLI_CONFIG_CONTENT)))
: undefined
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
return parseRecord(text)
})
const write = Effect.fnUntraced(function* (text: string) {
@@ -61,6 +61,9 @@ export const layer = Layer.effect(
}),
),
)
const load = Effect.fnUntraced(function* (migration?: Info) {
return merge(migration ?? Option.getOrUndefined(decode(yield* readJson())), content)
})
const get = Effect.fn("cli.config.get")(() =>
withLock(
@@ -72,8 +75,7 @@ export const layer = Layer.effect(
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
return yield* load(migration?.info)
}),
),
)
@@ -83,7 +85,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const current = yield* load(migration?.info)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
@@ -102,7 +104,7 @@ export const layer = Layer.effect(
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
return merge(config, content)
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
@@ -113,6 +115,39 @@ export const layer = Layer.effect(
type Edit = { readonly path: (string | number)[]; readonly value: any }
function merge(...values: readonly (Info | undefined)[]) {
return Option.getOrElse(
decode(
values.reduce<Record<string, unknown>>(
(result, value) => mergeRecords(result, value ?? {}),
{},
),
),
() => empty,
)
}
function mergeRecords(base: object, overlay: object) {
return Object.entries(overlay).reduce<Record<string, unknown>>(
(result, [key, value]) => {
result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value
return result
},
{ ...base },
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function parseRecord(text: string) {
const errors: ParseError[] = []
const value: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
}
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
@@ -29,7 +29,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.server.status({ signal: AbortSignal.timeout(5_000) }),
try: () => client.server.info({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== OPENCODE_VERSION)
+2 -2
View File
@@ -272,7 +272,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
fetch(request) {
const url = new URL(request.url)
requests?.push(url.pathname)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/model/default") return Response.json(located(null))
return fetch(request, url)
},
@@ -280,7 +280,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
}
function status() {
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
}
function located<T>(data: T) {
+45
View File
@@ -71,6 +71,51 @@ test("preserves the schema in an existing cli.json", async () => {
expect(await Bun.file(file).json()).toEqual(config)
})
test("merges inline CLI config content over the global config", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "cli.json")
const previous = process.env.OPENCODE_CLI_CONFIG_CONTENT
await Bun.write(
file,
JSON.stringify({
tabs: { enabled: true, scope: "global" },
keybinds: { "app.exit": "ctrl+q" },
plugins: ["global"],
animations: true,
}),
)
process.env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
tabs: { enabled: false },
keybinds: { "help.show": false },
plugins: ["inline"],
animations: false,
})
try {
const result = await run(
directory.path,
Effect.gen(function* () {
const service = yield* Config.Service
const loaded = yield* service.get()
const updated = yield* service.update((draft) => {
draft.animations = true
draft.mouse = false
})
return { loaded, updated }
}),
)
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
expect(result.loaded.plugins).toEqual(["inline"])
expect(result.updated).toMatchObject({ animations: false, mouse: false })
expect(await Bun.file(file).json()).toMatchObject({ animations: true, mouse: false })
} finally {
if (previous === undefined) delete process.env.OPENCODE_CLI_CONFIG_CONTENT
else process.env.OPENCODE_CLI_CONFIG_CONTENT = previous
}
})
test("migrates tui and kv config into cli.json", async () => {
await using directory = await tmpdir()
await Bun.write(
+7 -2
View File
@@ -39,9 +39,14 @@ describe("debug config command", () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") {
if (url.pathname === "/api/info") {
healthProbes += 1
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
}
requested = url
authorization.push(request.headers.get("authorization"))
+1
View File
@@ -4,6 +4,7 @@ export function isolatedEnv(root: string, overrides: Record<string, string | und
return {
...process.env,
HOME: root,
OPENCODE_CLI_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_CONTENT: "{}",
OPENCODE_CONFIG_DIR: path.join(root, "config"),
OPENCODE_DB: path.join(root, "opencode.db"),
@@ -12,7 +12,7 @@ await Effect.runPromise(
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
})
const response = yield* Effect.promise(() =>
fetch(new URL("/api/status", endpoint.url), { headers: Service.headers(endpoint) }),
fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }),
)
console.log(`STANDALONE_READY ${endpoint.pid} ${endpoint.url} ${response.status}`)
return yield* Effect.never
+7 -6
View File
@@ -42,7 +42,8 @@ const sanitizedTransfer = {
],
}
const status = () => Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
const status = () =>
Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
function run(args: string[], stdin?: string) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -60,7 +61,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
if (url.pathname === `/api/experimental/session/${info.id}/export`) {
sanitization.push(url.searchParams.get("sanitize") ?? "")
@@ -98,7 +99,7 @@ test("export requires a session outside an interactive terminal", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: "/project",
@@ -127,7 +128,7 @@ test("export reports a missing session without a stack trace", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === `/api/experimental/session/${sessionID}/export`) {
return Response.json(
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
@@ -158,7 +159,7 @@ test("import validates a file and sends it to the resolved location", async () =
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
@@ -201,7 +202,7 @@ test("import reports an existing session without a stack trace", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
+26 -6
View File
@@ -31,14 +31,24 @@ describe("mini command", () => {
const initial = Bun.serve({
port: 0,
fetch() {
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
const replacement = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization"))
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
const controller = new AbortController()
@@ -57,7 +67,7 @@ describe("mini command", () => {
})
const client = await connection.reconnect?.(controller.signal)
if (!client) throw new Error("Expected a replacement client")
await client.server.status()
await client.server.info()
expect(client).not.toBe(connection.sdk)
expect(signal).toBe(controller.signal)
@@ -147,8 +157,13 @@ describe("mini command", () => {
async fetch(request) {
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/api/status")
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
if (url.pathname === "/api/info")
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/session") {
@@ -190,7 +205,12 @@ describe("mini command", () => {
port: 0,
fetch(request) {
if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
return Response.json({ version: "incompatible", pid: process.pid, urls: [] })
return Response.json({
version: "incompatible",
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
+8 -4
View File
@@ -256,13 +256,14 @@ test("concurrent service processes elect one server", async () => {
expect((await Bun.file(config).json()).password).toBe(info.password)
expect(await Bun.file(registration + ".lock").exists()).toBe(false)
expect(
await fetch(new URL("/api/status", info.url), {
await fetch(new URL("/api/info", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
}).then((response) => response.json()),
).toEqual({
version: info.version,
pid: info.pid,
urls: [info.url],
paths: { tmp: path.join(os.tmpdir(), "opencode") },
})
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
@@ -337,7 +338,7 @@ test.each([
const info = await waitForInfo(registration)
await Promise.all(
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
const response = await fetch(new URL("/api/status", info.url), {
const response = await fetch(new URL("/api/info", info.url), {
method: "OPTIONS",
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
})
@@ -440,7 +441,10 @@ test("port contender recognizes an incumbent registered during the bind race", a
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }, { status: 503 })
return Response.json(
{ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } },
{ status: 503 },
)
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
@@ -575,7 +579,7 @@ async function waitForInfo(file: string, accept: (info: Info) => boolean = () =>
async function waitForFailed(info: Info) {
for (let attempt = 0; attempt < 400; attempt++) {
const status = await fetch(new URL("/api/status", info.url), {
const status = await fetch(new URL("/api/info", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
})
.then((response) => response.status)
+14 -4
View File
@@ -35,8 +35,13 @@ describe("web UI", () => {
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(request.url, "http://localhost").pathname
if (pathname === "/api/status")
return HttpServerResponse.jsonUnsafe({ version: "test", pid: 1, urls: [origin] })
if (pathname === "/api/info")
return HttpServerResponse.jsonUnsafe({
version: "test",
pid: 1,
urls: [origin],
paths: { tmp: "/tmp/opencode" },
})
return yield* Effect.fail(
new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({ request }),
@@ -47,8 +52,13 @@ describe("web UI", () => {
)
const origin = HttpServer.formatAddress(http.address)
const status = yield* Effect.promise(() => fetch(`${origin}/api/status`))
expect(yield* Effect.promise(() => status.json())).toEqual({ version: "test", pid: 1, urls: [origin] })
const status = yield* Effect.promise(() => fetch(`${origin}/api/info`))
expect(yield* Effect.promise(() => status.json())).toEqual({
version: "test",
pid: 1,
urls: [origin],
paths: { tmp: "/tmp/opencode" },
})
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
expect(missing.status).toBe(404)
+8 -3
View File
@@ -39,23 +39,28 @@ import type { Vcs } from "@opencode/schema/vcs"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
export type ServerStatusOutput = {
export type ServerInfoOutput = {
readonly version: string
readonly pid: number
readonly urls: ReadonlyArray<string>
readonly paths: { readonly tmp: string }
}
export type ServerStatusOperation<E = never> = () => Effect.Effect<ServerStatusOutput, E>
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
export interface ServerApi<E = never> {
readonly status: ServerStatusOperation<E>
readonly info: ServerInfoOperation<E>
}
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type LocationGetOutput = Location.PublicInfo
export type LocationGetOperation<E = never> = (input?: LocationGetInput) => Effect.Effect<LocationGetOutput, E>
export type LocationReloadOutput = void
export type LocationReloadOperation<E = never> = () => Effect.Effect<LocationReloadOutput, E>
export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E>
readonly reload: LocationReloadOperation<E>
}
export type AgentListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
+12 -5
View File
@@ -5,9 +5,10 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
ServerStatusOutput,
ServerInfoOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
@@ -277,17 +278,23 @@ const preserveStream =
<E, R>(stream: Stream.Stream<A, E, R>) =>
stream
const EndpointServerStatus = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerStatusOutput>()(raw["server.status"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ status: EndpointServerStatus(raw) })
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
preserveEffect<LocationGetOutput>()(
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({ get: EndpointLocationGet(raw) })
const EndpointLocationReload = (raw: RawClient["server.location"]) => () =>
preserveEffect<LocationReloadOutput>()(raw["location.reload"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({
get: EndpointLocationGet(raw),
reload: EndpointLocationReload(raw),
})
const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentListInput) =>
preserveEffect<AgentListOutput>()(
+8 -8
View File
@@ -172,7 +172,7 @@ export const Info = Schema.Struct({
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeStatus = Schema.decodeUnknownOption(
const decodeInfo = Schema.decodeUnknownOption(
Schema.Struct({
version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
@@ -213,7 +213,7 @@ const probeResult = Effect.fnUntraced(function* (
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = yield* Effect.promise(() =>
fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
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),
@@ -225,7 +225,7 @@ const probeResult = Effect.fnUntraced(function* (
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
if (response.status === 404)
return {
@@ -239,16 +239,16 @@ const probeResult = Effect.fnUntraced(function* (
timedOut: false,
}
const body = result.value.body
const status = decodeStatus(body)
if (Option.isSome(status)) {
if (status.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && status.value.version !== info.version)
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: status.value.version,
version: serverInfo.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
@@ -1,7 +1,8 @@
import type {
ServerStatusOutput,
ServerInfoOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
@@ -397,9 +398,9 @@ export function make(options: ClientOptions) {
return {
server: {
status: (requestOptions?: RequestOptions) =>
request<ServerStatusOutput>(
{ method: "GET", path: `/api/status`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
info: (requestOptions?: RequestOptions) =>
request<ServerInfoOutput>(
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
},
@@ -416,6 +417,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
reload: (requestOptions?: RequestOptions) =>
request<LocationReloadOutput>(
{
method: "POST",
path: `/api/location/reload`,
successStatus: 204,
declaredStatuses: [400, 401, 503],
empty: true,
},
requestOptions,
),
},
agent: {
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
+22 -10
View File
@@ -1,6 +1,6 @@
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
export type ServerStatus = { version: string; pid: number; urls: Array<string> }
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
@@ -820,6 +820,15 @@ export type SessionUsageRecorded = {
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
}
export type LocationShutdown = {
id: string
created: number
metadata?: { [x: string]: any }
type: "location.shutdown"
location?: LocationRef
data: {}
}
export type ModelsDevRefreshed = {
id: string
created: number
@@ -2321,6 +2330,7 @@ export type IntegrationInfo = {
}
export type V2Event =
| LocationShutdown
| ModelsDevRefreshed
| CredentialUpdated
| CredentialSwitched
@@ -2429,14 +2439,6 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
readonly message: string
}
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
@@ -2445,6 +2447,14 @@ export type ServiceUnavailableError = {
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
readonly message: string
}
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2645,7 +2655,7 @@ export type WorktreeError = {
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type ServerStatusOutput = ServerStatus
export type ServerInfoOutput = ServerInfo
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
@@ -2653,6 +2663,8 @@ export type LocationGetInput = {
export type LocationGetOutput = LocationPublicInfo
export type LocationReloadOutput = void
export type AgentListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+9 -8
View File
@@ -163,7 +163,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
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),
@@ -174,7 +174,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
if (response.status === 404)
return {
@@ -187,15 +187,16 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
} satisfies LocalService,
timedOut: false,
}
const status = decodeStatus(result.value.body)
if (status !== undefined) {
if (status.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && status.version !== info.version) return { service: undefined, 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: status.version,
version: serverInfo.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
@@ -205,7 +206,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
return { service: undefined, timedOut: false }
}
function decodeStatus(input: unknown) {
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
+6
View File
@@ -592,6 +592,12 @@ export function createData(config: CreateDataInput) {
function handleEvent(event: OpenCodeEvent) {
switch (event.type) {
case "location.shutdown": {
if (!event.location) return
result.location.invalidate(event.location)
refresh(() => result.location.sync(event.location))
return
}
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
activeUpdates = updates
+14 -4
View File
@@ -15,21 +15,31 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("server.status decodes the readiness response", async () => {
test("server.info decodes the readiness response", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ version: "current", pid: 123, urls: ["http://localhost:3000"] }),
Response.json({
version: "current",
pid: 123,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
}),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.server.status()
return yield* client.server.info()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({ version: "current", pid: 123, urls: ["http://localhost:3000"] })
expect(result).toEqual({
version: "current",
pid: 123,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
})
test("vcs.base decodes nullable review-base metadata", async () => {
+15 -4
View File
@@ -52,7 +52,7 @@ const server = Bun.serve({
await writeFile(registration + ".prepared", JSON.stringify(handoff))
return Response.json({ handoff })
}
if (pathname !== "/api/status") return new Response(null, { status: 404 })
if (pathname !== "/api/info") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
if (mode === "hanging") {
@@ -65,10 +65,21 @@ const server = Bun.serve({
return new Response(null, { status: 503 })
}
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 503 })
return Response.json(
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
{ status: 503 },
)
if (mode === "failed-owner")
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 500 })
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] })
return Response.json(
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
{ status: 500 },
)
return Response.json({
version,
pid: process.pid,
urls: [server.url.toString()],
paths: { tmp: "/tmp/opencode" },
})
},
})
+24 -8
View File
@@ -192,19 +192,29 @@ test("websearch.query uses the public HTTP contract", async () => {
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
})
test("server.status uses the public HTTP contract", async () => {
test("server.info uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: ["http://192.168.1.10:4096"],
paths: { tmp: "/tmp/opencode" },
})
},
})
expect(await client.server.status()).toEqual({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
expect(await client.server.info()).toEqual({
version: "2.0.0",
pid: 1,
urls: ["http://192.168.1.10:4096"],
paths: { tmp: "/tmp/opencode" },
})
expect(request?.method).toBe("GET")
expect(request?.url).toBe("http://localhost:3000/api/status")
expect(request?.url).toBe("http://localhost:3000/api/info")
})
test("experimental wellknown integration add uses the public HTTP contract", async () => {
@@ -686,7 +696,7 @@ test("event.subscribe reports heartbeat comments as stream activity", async () =
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary status requests", async () => {
test("event transport passes through ordinary info requests", async () => {
const requests: string[] = []
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
@@ -699,16 +709,22 @@ test("event transport passes through ordinary status requests", async () => {
headers: { "content-type": "text/event-stream" },
})
}
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://localhost:3000"] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
},
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
await expect(client.server.status()).resolves.toEqual({
await expect(client.server.info()).resolves.toEqual({
version: "2.0.0",
pid: 1,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
expect(requests).toEqual(["/api/event", "/api/status"])
expect(requests).toEqual(["/api/event", "/api/info"])
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
+1 -1
View File
@@ -300,5 +300,5 @@ function run<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) {
}
async function status(url: string) {
return fetch(new URL("/api/status", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
return fetch(new URL("/api/info", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}
+6 -1
View File
@@ -182,7 +182,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors.
non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -472,6 +473,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
- [x] An Error crosses, in either direction, as its name, message, `cause`, and own enumerable data, so Node's
`code`, `errno`, `syscall`, and `path` reach the program and `err.code === "ENOENT"` works. `stack` stays on its
own side, no field may shadow an Error method, and a field that cannot cross (a class instance, a function) is
left behind rather than replacing the error.
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
step if `new X()` in a program is ever needed.
+12 -4
View File
@@ -6,7 +6,7 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { formatValue } from "../stdlib/console.js"
import { coerceToString } from "../stdlib/value.js"
@@ -146,9 +146,17 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
const prototype = builtins[type]
const construct = (args: Array<unknown>, newTarget: Callable) => {
const proto = prototypeFrom(newTarget, prototype)
return type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
const created =
type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
const options = args[type === "AggregateError" ? 2 : 1]
if (!(options instanceof Obj) || !has(options, "cause")) return created
return Effect.map(created, (value) => {
define(value, "cause", get(options, "cause"), hidden)
return value
})
}
const ctor: Native<R> = constructor<R>(builtins, prototype, {
name: type,
@@ -5,13 +5,16 @@ import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
import { Throw, typeError } from "./model.js"
import { PendingThrow, Throw, typeError } from "./model.js"
import { fn } from "./native.js"
import {
Callable,
define,
entries,
get,
has,
hidden,
keys,
Arr,
Bytes,
DateObj,
@@ -60,14 +63,28 @@ export const extensionGlobals = <R>(
) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
if (value instanceof ErrorObj) {
const name = coerceToString(get(value, "name"))
const message = get(value, "message")
const text = message === undefined ? "" : coerceToString(message)
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
const copied =
name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
for (const key of new Set(["cause", ...keys(value)])) {
if (uncrossed.has(key) || !has(value, key)) continue
const item = crossing(() => next(get(value, key)))
if (item === left) continue
Object.defineProperty(copied, key, {
value: item,
writable: true,
configurable: true,
enumerable: key !== "cause",
})
}
seen.delete(value)
return copied
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
const copied =
value instanceof Arr
? value.items.map(next)
@@ -85,18 +102,29 @@ export const extensionGlobals = <R>(
if (isPrimitive(value)) return value
if (typeof value === "function") return wrap(value, label)
if (value !== null && typeof value === "object") {
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
if (value instanceof ArrayBuffer) return new Bytes(builtins.Uint8Array, new Uint8Array(value.slice(0)))
if (value instanceof Error) {
return createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
seen.add(value)
const copied = createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
const fields = value as unknown as Record<string, unknown>
for (const key of new Set(["cause", ...Object.keys(value)])) {
if (uncrossed.has(key) || !(key in value) || typeof fields[key] === "function") continue
const item = crossing(() => next(fields[key], `${label}.${key}`))
if (item === left) continue
define(copied, key, item, key === "cause" ? hidden : undefined)
}
seen.delete(value)
return copied
}
if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href))
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
@@ -160,6 +188,22 @@ export const extensionGlobals = <R>(
)
}
/**
* An error crosses as its name, message, `cause`, and own enumerable fields, such as Node's `code`, `errno`,
* `syscall`, and `path`. `stack` stays on its own side, and no field may shadow an Error method. A field that cannot
* cross (a socket, a handle, a function) is left behind so the error itself always arrives.
*/
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
const left = Symbol("left behind")
const crossing = (convert: () => unknown): unknown => {
try {
return convert()
} catch (reason) {
if (reason instanceof PendingThrow) return left
throw reason
}
}
const hostErrors = new Map<string, ErrorConstructor>([
["TypeError", TypeError],
["RangeError", RangeError],
+58 -1
View File
@@ -163,12 +163,26 @@ describe("values are converted at the boundary, never shared", () => {
expect(held[0]).toEqual({ a: 1 })
})
test("a program Error crosses as a host Error with its name and message", async () => {
test("a program Error crosses as a host Error with its name, message, cause, and own data", async () => {
held.length = 0
await value(`keep(new TypeError("bad"))`)
expect(held[0]).toBeInstanceOf(TypeError)
expect((held[0] as Error).message).toBe("bad")
expect(Object.keys(held[0] as object)).toEqual([])
held.length = 0
await value(`
const e = new Error("m", { cause: new RangeError("root") })
e.code = "ENOENT"; e.detail = { path: "x" }
e.stack = "chosen"; e.toString = 1; e.constructor = 2; e.fn = () => 1
keep(e)`)
const crossed = held[0] as Error & Record<string, unknown>
expect(crossed.cause).toBeInstanceOf(RangeError)
expect((crossed.cause as Error).message).toBe("root")
expect(Object.keys(crossed)).toEqual(["code", "detail"])
expect(crossed.detail).toEqual({ path: "x" })
expect(crossed.stack).not.toBe("chosen")
expect(String(crossed)).toBe("Error: m")
expect(crossed.constructor).toBe(Error)
})
test("an Error with an unknown name crosses as a plain Error", async () => {
@@ -231,6 +245,49 @@ describe("host errors", () => {
])
})
test("a host Error arrives with its cause and own data; what cannot cross is left behind", async () => {
class Handle {}
const target = CodeMode.make({
extensions: [
Extension.make({
name: "fs",
globals: {
open: () => {
const error = Object.assign(new Error("ENOENT: no such file or directory, open 'x'"), {
code: "ENOENT",
errno: -2,
path: "x",
detail: { retried: true },
handle: new Handle(),
retry: () => 1,
})
throw new Error("open failed", { cause: error })
},
},
}),
],
})
expect(
await value(
`try { open() } catch (e) {
const c = e.cause
return [e.message, Object.keys(e), c instanceof Error, c.code, c.errno, c.path, c.detail, Object.keys(c), "stack" in c]
}`,
target,
),
).toEqual([
"open failed",
[],
true,
"ENOENT",
-2,
"x",
{ retried: true },
["code", "errno", "path", "detail"],
false,
])
})
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
const reason = { status: 404, nested: { a: 1 } }
const target = CodeMode.make({
+8 -8
View File
@@ -235,7 +235,7 @@ describe("OpenAPI.fromSpec", () => {
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "server.status")).not.toBeUndefined()
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
@@ -978,10 +978,10 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const status = toolAt(result.tools, "server.status")
const statusInput = Tool.isTool(status) && isRecord(status.input) ? status.input : undefined
expect(statusInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(statusInput) ? statusInput : {}
const info = toolAt(result.tools, "server.info")
const infoInput = Tool.isTool(info) && isRecord(info.input) ? info.input : undefined
expect(infoInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(infoInput) ? infoInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
@@ -994,7 +994,7 @@ describe("OpenAPI.fromSpec", () => {
runtime
.execute(
`
return search({ query: "server status", namespace: "opencode", limit: 1 })
return search({ query: "server info", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
@@ -1005,8 +1005,8 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.opencode.server.status",
description: "Return the server identity, connection URLs, and readiness status.",
path: "tools.opencode.server.info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
},
],
})
+10
View File
@@ -350,6 +350,16 @@ describe("Error values and instanceof", () => {
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
})
test("new Error(message, { cause }) installs a non-enumerable cause only when the option is present", async () => {
expect(
await value(`
const inner = new Error("root")
const e = new TypeError("m", { cause: inner })
const agg = new AggregateError([], "a", { cause: 3 })
return [e.cause === inner, Object.keys(e), "cause" in new Error("m"), "cause" in new Error("m", { cause: undefined }), agg.cause]`),
).toEqual([true, [], false, true, 3])
})
test("thrown errors keep instanceof through try/catch", async () => {
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
true,
+14 -9
View File
@@ -79,6 +79,7 @@ export interface ListInput {
}
export interface Interface {
readonly close: Effect.Effect<void>
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
@@ -100,6 +101,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
let closed = false
const forms = yield* Cache.makeWith<ID, Entry>(
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
{
@@ -137,6 +139,7 @@ export const layer = Layer.effect(
}
yield* Cache.set(forms, id, entry)
yield* bus.publish(Form.Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
if (closed) yield* cancel(id).pipe(Effect.orDie)
return form
}),
),
@@ -202,19 +205,21 @@ export const layer = Layer.effect(
),
)
yield* Effect.addFinalizer(() =>
Cache.values(forms).pipe(
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
const close = Effect.sync(() => {
closed = true
}).pipe(
Effect.andThen(Cache.values(forms)),
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
),
)
yield* Effect.addFinalizer(() => close)
return Service.of({ create, ask, get, list, state, reply, cancel })
return Service.of({ create, ask, get, list, state, reply, cancel, close })
}),
)
+4 -1
View File
@@ -1,4 +1,4 @@
import { Effect, Layer } from "effect"
import { Context, Effect, Layer } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Model } from "./model.js"
@@ -18,6 +18,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
import { FileAccess } from "./file-access.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
@@ -57,6 +58,7 @@ export { Service, node, type Interface } from "./instance/service.js"
const nodes = [
Location.node,
LocationLifecycle.node,
Environment.node,
Config.node,
Agent.node,
@@ -157,6 +159,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
// Instance boot failures are defects; provided operations retain their typed errors.
Layer.orDie,
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
+52
View File
@@ -0,0 +1,52 @@
export * as LocationLifecycle from "./location-lifecycle.js"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LocationEvent } from "@opencode/schema/location-event"
import { Bus } from "./bus.js"
import { Form } from "./form.js"
import { Location } from "./location.js"
import { Permission } from "./permission.js"
import { Rpc } from "./rpc.js"
export class Service extends Context.Service<
Service,
{ readonly isClosed: () => boolean; readonly shutdown: Effect.Effect<void> }
>()("@opencode/LocationLifecycle") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
const forms = yield* Form.Service
const rpc = yield* Rpc.Service
let closed = false
const shutdown = yield* Effect.cached(
Effect.gen(function* () {
closed = true
yield* permission.close
yield* forms.close
yield* rpc.close
yield* bus.publish(
LocationEvent.Shutdown,
{},
{
location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
},
)
}).pipe(Effect.uninterruptible),
)
return Service.of({
isClosed: () => closed,
shutdown,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
})
+19 -1
View File
@@ -1,4 +1,4 @@
import { Context, Effect, Layer, LayerMap } from "effect"
import { Context, Effect, Exit, Layer, LayerMap, RcMap } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Node } from "@opencode/util/effect/app-node"
import { AbsolutePath } from "@opencode/schema/schema"
@@ -17,6 +17,24 @@ export class Service extends Context.Service<
export const node = LayerNode.unbound(Service, Node.tags.values.global)
export const reload = Effect.fn("LocationServiceMap.reload")(function* () {
const locations = yield* Service
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
yield* Effect.forEach(refs, (ref) => locations.invalidate(ref), {
discard: true,
concurrency: "unbounded",
})
// Boot every replacement now and let all builds settle even if one fails.
const results = yield* Effect.forEach(
refs,
(ref) => Effect.scoped(locations.contextEffect(ref)).pipe(Effect.asVoid, Effect.exit),
{ concurrency: "unbounded" },
)
const failure = results.find(Exit.isFailure)
if (failure) return yield* Effect.failCause(failure.cause)
yield* Effect.logInfo("location services reloaded", { count: refs.length })
})
/** Normalize equivalent placements before they become resource-cache keys. */
export function canonical(ref: Location.Ref) {
return Location.Ref.make({
+13 -7
View File
@@ -2,8 +2,8 @@ import { Context, Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Optio
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Rpc } from "./rpc.js"
export { LocationServiceMap } from "./location-service-map.js"
@@ -28,12 +28,17 @@ export function buildLocationServiceMap(
).pipe(
Effect.onExit((exit) => {
const finish = Effect.suspend(() => {
if (Exit.isSuccess(exit)) {
return Effect.gen(function* () {
const lifecycle = Context.get(exit.value, LocationLifecycle.Service)
// A boot detached while in flight still needs shutdown and cancellation.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build)
return yield* lifecycle.shutdown
build.close = lifecycle.shutdown
})
}
// An explicitly invalidated build must not evict its replacement.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build) return Effect.void
if (Exit.isSuccess(exit)) {
build.close = Context.get(exit.value, Rpc.Service).close
return Effect.void
}
MutableHashMap.remove(builds, ref)
// Evict once per failed build, before its result reaches borrowers.
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
@@ -61,10 +66,11 @@ export function buildLocationServiceMap(
const key = LocationServiceMap.canonical(ref)
const build = Option.getOrUndefined(MutableHashMap.get(builds, key))
MutableHashMap.remove(builds, key)
// Detach routing first, then end pending RPCs that still borrow the old graph.
// Detach routing first, then cancel interactions and notify clients. Running
// steps retain their borrowed graph until they can hand off at a boundary.
// Do not await a boot here: failed/in-flight builds have their own cleanup path.
return inner.invalidate(key).pipe(Effect.andThen(build?.close ?? Effect.void))
}),
}).pipe(Effect.uninterruptible),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
+23 -12
View File
@@ -101,6 +101,7 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly close: Effect.Effect<void>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -127,18 +128,22 @@ const layer = Layer.effect(
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
discard: true,
}).pipe(
Effect.ensuring(
Effect.sync(() => {
pending.clear()
}),
),
),
)
const close = Effect.gen(function* () {
closed = true
yield* Effect.forEach(Array.from(pending.values()), (item) =>
bus
.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
.pipe(Effect.ensuring(Deferred.fail(item.deferred, new DeclinedError()))),
)
pending.clear()
}).pipe(Effect.uninterruptible)
yield* Effect.addFinalizer(() => close)
const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
@@ -201,6 +206,10 @@ const layer = Layer.effect(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
if (closed) {
yield* Deferred.fail(deferred, new DeclinedError())
return item
}
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
pending.set(request.id, item)
@@ -212,6 +221,7 @@ const layer = Layer.effect(
)
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
if (closed) return { id: input.id ?? ID.create(), effect: "deny" as const }
const result = yield* evaluateInput(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
@@ -220,6 +230,7 @@ const layer = Layer.effect(
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.gen(function* () {
if (closed) return yield* Effect.die(new DeclinedError())
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -321,7 +332,7 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list })
return Service.of({ ask, assert, reply, get, forSession, list, close })
}),
)
+13 -9
View File
@@ -42,13 +42,17 @@ that section.
CLI and TUI preferences are separate from OpenCode's server and project
configuration. They live in the global `~/.config/opencode/cli.json`, or
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
project-local CLI configuration. Most preferences can also be changed from the
TUI by pressing `Ctrl+P` and selecting **Open settings**.
project-local CLI configuration. Set `OPENCODE_CLI_CONFIG_CONTENT` to merge
inline JSON over the global settings. Most preferences can also be changed from
the TUI by pressing `Ctrl+P` and selecting **Open settings**.
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It covers terminal-only settings such as themes,
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
and terminal integration. Do not put these settings in `opencode.json(c)`.
### [Settings](https://opencode.ai/v2/docs/cli/config)
Fetch the full [CLI settings reference](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It documents every terminal-only setting, accepted
values, and examples, including themes, input, sessions, tabs, diffs, alerts,
Mini, keybindings, terminal plugins, and debugging. Do not put these settings
in `opencode.json(c)`.
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
@@ -92,7 +96,7 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
`references`, `formatter`, and `lsp`.
This configuration is distinct from `cli.json`. Use the
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
[CLI settings reference](https://opencode.ai/v2/docs/cli/config) for terminal
preferences, especially themes and keybindings.
Do not guess field names or shapes. Fetch the V2 configuration guide and its
@@ -190,7 +194,7 @@ HTTP method and path or an OpenAPI operation ID.
Call an endpoint with an HTTP method and path:
```sh
opencode api get /api/status
opencode api get /api/info
```
Pass a request body with `--data` or `-d`, and additional headers with
@@ -241,7 +245,7 @@ OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
- Check the service with `opencode service status` and verify the API with
`opencode api get /api/status`.
`opencode api get /api/info`.
- Compare with `opencode --standalone`, which runs the TUI with a private
server, to isolate shared-service issues.
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
+3
View File
@@ -57,6 +57,9 @@ export const Plugin = define({
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const session = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(Effect.orDie)
if (session.parentID) return
const active = sessions.get(event.sessionID)
const settings = yield* loadSettings()
if (!settings) {
+1
View File
@@ -108,6 +108,7 @@ export const layer = Layer.effect(
return yield* SessionRunner.DrainResult.$match(result, {
Complete: () => Effect.void,
Moved: (result) => drain(sessionID, false, result.continuation, promotable),
Reloaded: (result) => drain(sessionID, result.force, result.continuation, promotable),
})
})
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
+18 -9
View File
@@ -320,15 +320,24 @@ export const layer = Layer.effect(
// which transport actually carries the request, so both hook families are always offered.
const webSocket =
input.webSocket === "session" && model.transport === "websocket"
? transport.bind(session.id, (connect) =>
hooks
.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
})
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
)
? transport.bind(session.id, {
handshake: (connect) =>
hooks
.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
})
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
send: (frame) =>
hooks
.trigger("session", "experimental.ws.send", { ...scope, frame })
.pipe(Effect.map((event) => event.frame)),
receive: (frame) =>
hooks
.trigger("session", "experimental.ws.receive", { ...scope, frame })
.pipe(Effect.map((event) => event.frame)),
})
: undefined
return {
+21 -13
View File
@@ -59,11 +59,18 @@ export interface Handshake {
readonly headers: Record<string, string>
}
/**
* Per-exchange taps. `handshake` runs before the connection is selected; `send` sees each outbound
* frame after the driver builds it; `receive` sees each inbound frame before the driver observes it.
*/
export interface Interceptor {
readonly handshake?: (connect: Handshake) => Effect.Effect<Handshake>
readonly send?: (frame: string) => Effect.Effect<string>
readonly receive?: (frame: string) => Effect.Effect<string>
}
export interface Interface {
readonly bind: (
sessionID: SessionSchema.ID,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
) => WebSocketChannelExecutor
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
@@ -278,7 +285,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
input: WebSocketChannelExchange,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
interceptor?: Interceptor,
) {
if (owner.closed)
return yield* transportError("Session WebSocket owner is closed", {
@@ -288,8 +295,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "not-sent",
})
if (owner.httpFallback) return fallback(input)
const selected = handshake
? yield* handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
const selected = interceptor?.handshake
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
: undefined
const exchange: WebSocketChannelExchange = selected
? { ...input, connect: { ...input.connect, url: selected.url, headers: Headers.fromInput(selected.headers) } }
@@ -354,6 +361,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const message = interceptor?.send
? yield* interceptor.send(create.message).pipe(Effect.onInterrupt(() => closeChannel(owner, channel)))
: create.message
yield* Effect.logDebug("session websocket sending", {
sessionTransport: "websocket",
phase: "send",
@@ -364,7 +374,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "send-attempted",
}
channel.active = active
const sent = yield* channel.connection.sendText(create.message).pipe(
const sent = yield* channel.connection.sendText(message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
Effect.result,
@@ -405,6 +415,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
}),
),
}),
Stream.mapEffect((frame) => (interceptor?.receive ? interceptor.receive(frame) : Effect.succeed(frame))),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
@@ -482,10 +493,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
return { frames, complete, http: channel.connection.http }
})
const bind = (
sessionID: SessionSchema.ID,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
): WebSocketChannelExecutor => ({
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
let execution: WebSocketChannelExecution | undefined
@@ -495,7 +503,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
},
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange, handshake)),
Effect.andThen(start(owner, exchange, interceptor)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
@@ -22,6 +22,7 @@ export type Continuation = { readonly step: number }
export type DrainResult = Data.TaggedEnum<{
Complete: {}
Moved: { readonly continuation?: Continuation }
Reloaded: { readonly force: boolean; readonly continuation?: Continuation }
}>
export const DrainResult = Data.taggedEnum<DrainResult>()
+8
View File
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from "drizzle-orm"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { LocationLifecycle } from "../../location-lifecycle.js"
import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
@@ -37,6 +38,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const lifecycle = yield* LocationLifecycle.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelTransport = yield* SessionModelTransport.Service
@@ -69,6 +71,10 @@ const layer = Layer.effect(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
while (true) {
if (lifecycle.isClosed()) {
yield* restore(modelTransport.close(sessionID))
return DrainResult.Reloaded({ force, continuation: continuing ? { step } : undefined })
}
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
const pending = yield* SessionInbox.serialized(
sessionID,
@@ -240,6 +246,7 @@ const layer = Layer.effect(
webSocket: "session",
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
sessionID,
assistantMessageID,
agent: loaded.agent.id,
@@ -356,6 +363,7 @@ export const node = makeLocationNode({
layer,
deps: [
Bus.node,
LocationLifecycle.node,
llmClient,
SessionContext.node,
SessionModelTransport.node,
+8 -3
View File
@@ -42,6 +42,7 @@ export type Outcome = Data.TaggedEnum<{
export const Outcome = Data.taggedEnum<Outcome>()
interface Input {
readonly isLocationClosed: () => boolean
readonly sessionID: SessionSchema.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
@@ -190,8 +191,9 @@ export const make = Effect.gen(function* () {
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
message: input.isLocationClosed()
? "Interaction cancelled because the location shut down"
: decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
@@ -251,7 +253,10 @@ export const make = Effect.gen(function* () {
return Outcome.Continue({ error: llmError, decision: retry })
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.declines.length > 0) {
if (input.isLocationClosed()) return Outcome.Completed({ needsContinuation: true })
return yield* Effect.interrupt
}
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
+2 -2
View File
@@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () =>
it.effect("classifies data-only AI SDK provider codes", () =>
Effect.gen(function* () {
const data = {
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } },
trace: { region: "test-region" },
}
const cause = apiCallError({ statusCode: 400, data })
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason.http?.status).toBe(400)
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect(error.reason.body).toBe(JSON.stringify(data))
+2 -2
View File
@@ -1,5 +1,5 @@
import { Permission } from "@opencode/core/permission"
import { Layer } from "effect"
import { Effect, Layer } from "effect"
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
Layer.mock(Permission.Service, overrides)
Layer.mock(Permission.Service, { close: Effect.void, ...overrides })
+1
View File
@@ -55,6 +55,7 @@ const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text
const permissionLayer = Layer.succeed(
Permission.Service,
Permission.Service.of({
close: Effect.void,
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
assert: () => Effect.void,
reply: () => Effect.void,
@@ -80,7 +80,7 @@ describe("SessionModelRequest HTTP hooks", () => {
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the handshake hook", () =>
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the WebSocket hooks", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
@@ -92,13 +92,31 @@ describe("SessionModelRequest HTTP hooks", () => {
delete event.headers["api-key"]
}),
)
yield* hooks.register("session", "experimental.ws.send", (event) =>
Effect.sync(() => {
seen.push(`send:${event.kind}:${event.frame}`)
event.frame = `${event.frame}+plugin`
}),
)
yield* hooks.register("session", "experimental.ws.receive", (event) =>
Effect.sync(() => {
seen.push(`receive:${event.kind}:${event.frame}`)
event.frame = event.frame.toUpperCase()
}),
)
const bound: Array<{ url: string; headers: Record<string, string> }> = []
const frames: string[] = []
const websocketTransport = SessionModelTransport.Service.of({
bind: (_sessionID, handshake) => ({
bind: (_sessionID, interceptor) => ({
execute: () =>
Effect.gen(function* () {
if (!handshake) throw new Error("Expected a handshake interceptor")
bound.push(yield* handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }))
if (!interceptor?.handshake || !interceptor.send || !interceptor.receive)
throw new Error("Expected a full WebSocket interceptor")
bound.push(
yield* interceptor.handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }),
)
frames.push(yield* interceptor.send("create"))
frames.push(yield* interceptor.receive("created"))
return { frames: Stream.empty, complete: Effect.void }
}),
}),
@@ -127,7 +145,12 @@ describe("SessionModelRequest HTTP hooks", () => {
expect(prepared.options.webSocket).toBeDefined()
yield* prepared.options.webSocket!.execute({} as never)
expect(bound).toEqual([{ url: "wss://example.test/v1/responses", headers: { authorization: "Bearer minted" } }])
expect(seen).toEqual(["handshake:primary:wss://example.test/v1/responses"])
expect(frames).toEqual(["create+plugin", "CREATED"])
expect(seen).toEqual([
"handshake:primary:wss://example.test/v1/responses",
"send:primary:create",
"receive:primary:created",
])
}),
)
})
@@ -178,12 +178,13 @@ describe("SessionModelTransport", () => {
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session, (connect) =>
Effect.succeed({
url: connect.url,
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
}),
)
const executor = transport.bind(session, {
handshake: (connect) =>
Effect.succeed({
url: connect.url,
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
}),
})
yield* collect(executor, exchange("first", { headers: { "api-key": "k" } }))
yield* collect(executor, exchange("second", { headers: { "api-key": "k" } }))
yield* collect(executor, exchange("third", { headers: { "api-key": "k" } }))
@@ -196,6 +197,36 @@ describe("SessionModelTransport", () => {
)
})
test("sends the frame the send tap returns and observes the frame the receive tap returns", async () => {
const fixture = automatic()
const seen: Array<{ tap: "send" | "receive"; frame: string }> = []
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session, {
send: (frame) => {
seen.push({ tap: "send", frame })
return Effect.succeed(`${frame}:rewritten`)
},
receive: (frame) => {
seen.push({ tap: "receive", frame })
return Effect.succeed(`${frame}:observed`)
},
})
const frames = yield* collect(executor, exchange("first"))
// The wire carries the rewritten outbound frame; the driver sees the rewritten inbound frame.
expect(fixture.connections.map((item) => item.sent)).toEqual([["first:rewritten"]])
expect(frames).toEqual(["completed:first:rewritten:observed"])
expect(seen).toEqual([
{ tap: "send", frame: "first" },
{ tap: "receive", frame: "completed:first:rewritten" },
])
}),
)
})
test("does not carry a checkpoint across physical connection rotation", async () => {
const fixture = automatic()
const checkpoints: Array<unknown> = []
+1
View File
@@ -100,6 +100,7 @@ for (const fixture of [
)
const result = yield* steps
.attempt({
isLocationClosed: () => false,
sessionID,
assistantMessageID,
agent: Agent.defaultID,
@@ -15,6 +15,31 @@ test.each(["build", "serve"] as const)("configures minification for %s", async (
expect(result?.config.renderer?.build?.sourcemap).toBe(true)
})
test("onboarding preview is enabled only by the development test flag", async () => {
const previous = process.env.OPENCODE_TEST_ONBOARDING
try {
process.env.OPENCODE_TEST_ONBOARDING = "1"
for (const command of ["build", "serve"] as const) {
const result = await loadConfigFromFile(
{ command, mode: command === "build" ? "production" : "development" },
`${import.meta.dirname}/electron.vite.config.ts`,
)
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe(
JSON.stringify(command === "serve"),
)
}
delete process.env.OPENCODE_TEST_ONBOARDING
const result = await loadConfigFromFile(
{ command: "serve", mode: "development" },
`${import.meta.dirname}/electron.vite.config.ts`,
)
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe("false")
} finally {
delete process.env.OPENCODE_TEST_ONBOARDING
if (previous !== undefined) process.env.OPENCODE_TEST_ONBOARDING = previous
}
})
test("does not package external copies of bundled dependencies", () => {
for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) {
expect(Object.keys(pkg.dependencies)).not.toContain(name)
+3
View File
@@ -96,6 +96,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
define: {
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
"import.meta.env.OPENCODE_TEST_ONBOARDING": JSON.stringify(
command === "serve" && process.env.OPENCODE_TEST_ONBOARDING === "1",
),
},
plugins: [pickerPlugin(), appPlugin, sentry],
publicDir: "../../../app/public",
@@ -42,7 +42,10 @@ export const configureApplication = Effect.fn("Application.configure")(function*
const testRoot = yield* createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", path.join(testRoot, "session"))
if (testRoot) {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
})
export function acquireApplicationLock() {
@@ -99,7 +102,7 @@ const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
if (!root) return undefined
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
yield* Effect.forEach(
["data", "config", "cache", "state", "desktop", "session"],
["data", "config", "cache", "state", "desktop", "session", "documents"],
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
{ discard: true },
)
+1 -1
View File
@@ -6,7 +6,7 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
try {
const res = await fetch(new URL("/api/status", url), {
const res = await fetch(new URL("/api/info", url), {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
+1 -1
View File
@@ -354,7 +354,7 @@ const waitReady = Effect.fn("Ssh.waitReady")(function* (http: SshHttp, authentic
const checkHealth = Effect.fn("Ssh.checkHealth")(function* (http: SshHttp) {
const client = yield* HttpClient.HttpClient
return yield* client
.get(`${http.url}/api/status`, {
.get(`${http.url}/api/info`, {
headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` },
})
.pipe(
@@ -120,6 +120,10 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
revealed = true
win.show()
if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") {
if (process.platform === "darwin") app.focus({ steal: true })
win.focus()
}
runFork(Effect.logInfo("main window visible", { window: id }))
}
const ready = () => {
+61 -46
View File
@@ -23,7 +23,7 @@ import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
import { createDesktopPlatform } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
import { preloadStoredLocale } from "./startup/locale"
@@ -37,53 +37,41 @@ const MigrationStatus = lazy(() => import("./migration-status").then((module) =>
export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
const windowState = { id: props.api.getWindowID(), version: props.version }
const url = new URL(getLastActiveUrl(windowState.id), "http://localhost")
const initialUrl = getLastActiveUrl(windowState.id)
const url = new URL(initialUrl, "http://localhost")
const route = currentRoute(url.pathname, url.search)
const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({
const [startup, setStartup] = createStore({
ready: false,
visible: true,
themeReady: false,
onboardingReady: false,
drawingReady: false,
route,
})
return (
<>
<DesktopWindow
api={props.api}
updater={props.updater}
windowState={windowState}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<Show when={startup.visible}>
<div
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": startup.ready }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !startup.ready) return
setStartup("visible", false)
}}
>
<LoadingSplash deep={startup.route.type === "draft"} />
</div>
</Show>
</>
const [firstLaunch] = createResource(() =>
props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
)
}
function DesktopWindow(props: {
api: ElectronAPI
updater: UpdaterPlatform
windowState: DesktopWindowState
onReady: () => void
onRoute: (route: LayoutRoute) => void
}) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const platform = createDesktopPlatform(props.api, windowState, props.updater)
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
const router = (routerProps: BaseRouterProps) => (
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
const [initialRoute] = createResource(
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
preloadRoute,
)
const router = (routerProps: BaseRouterProps) => <DesktopMemoryRouter {...routerProps} windowID={windowState.id} />
const readyToReveal = () =>
startup.ready &&
(!import.meta.env.OPENCODE_TEST_ONBOARDING || !firstLaunch() || initialUrl !== "/" || startup.drawingReady)
// Reveal only after the theme and the first-launch splash choice are both resolved.
createEffect(() => {
if (!startup.themeReady || firstLaunch.loading) return
void props.api.themeReady()
})
function ReadyApp() {
const wslServers = useWslServers()
@@ -91,7 +79,13 @@ function DesktopWindow(props: {
const sshConnections = createSshConnections(props.api.sshServers)
const language = useLanguage()
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading && !ssh.loading,
() =>
!firstLaunch.loading &&
!defaultServer.loading &&
!sidecar.loading &&
!locale.loading &&
!wslServers.isLoading &&
!ssh.loading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
@@ -119,14 +113,16 @@ function DesktopWindow(props: {
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopStartupReady
routeReady={() => !initialRoute.loading}
onReady={props.onReady}
onRoute={props.onRoute}
routeReady={!initialRoute.loading && startup.onboardingReady}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={getLastActiveUrl(props.windowState.id)}
initialUrl={initialUrl}
serverKey={key}
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<Suspense fallback={null}>
@@ -148,17 +144,36 @@ function DesktopWindow(props: {
onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)}
onThemeApplied={(mode, scheme) => {
void props.api.setTitlebar({ mode, scheme })
void props.api.themeReady()
setStartup("themeReady", true)
}}
>
<Show when={true}>{(_) => <ReadyApp />}</Show>
<Show when={!firstLaunch.loading && startup.visible}>
<div
data-component="startup-overlay"
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": readyToReveal() }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !readyToReveal()) return
setStartup("visible", false)
}}
>
<LoadingSplash
firstLaunch={!!firstLaunch() && initialUrl === "/"}
deep={startup.route.type === "draft"}
platform={platform}
preview={import.meta.env.OPENCODE_TEST_ONBOARDING}
onDrawEnd={() => setStartup("drawingReady", true)}
/>
</div>
</Show>
</AppBaseProviders>
</PlatformProvider>
)
}
function DesktopStartupReady(props: {
routeReady: () => boolean
routeReady: boolean
onReady: () => void
onRoute: (route: LayoutRoute) => void
}) {
@@ -166,7 +181,7 @@ function DesktopStartupReady(props: {
const route = useCurrentRoute()
createEffect(() => props.onRoute(route()))
createEffect(() => {
if (!props.routeReady() || !tabs.ready() || !tabs.infoReady()) return
if (!props.routeReady || !tabs.ready() || !tabs.infoReady()) return
props.onReady()
})
return null
+4
View File
@@ -1,6 +1,10 @@
import type { ElectronNative } from "../preload/types"
declare global {
interface ImportMetaEnv {
readonly OPENCODE_TEST_ONBOARDING: boolean
}
interface Window {
electron: ElectronNative
__OPENCODE__?: {
@@ -1,31 +1,38 @@
import { ServerConnection, useServers, useTabs } from "@opencode/app/desktop"
import { onMount } from "solid-js"
import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop"
import { createResource } from "solid-js"
import type { ElectronAPI } from "../api-types"
export function DesktopFirstLaunchOnboarding(props: {
api: ElectronAPI
serverKey: ServerConnection.Key
initialUrl: string
pending: boolean
onReady: () => void
}) {
const server = useServers()
const global = useGlobal()
const tabs = useTabs()
const route = useCurrentRoute()
onMount(() => {
void runFirstLaunchOnboarding()
const [completed] = createResource(async () => {
await runFirstLaunchOnboarding()
return null
})
async function runFirstLaunchOnboarding() {
try {
const pending = await props.api.isFirstLaunchOnboardingPending()
if (!pending) return
if (!props.pending) return
await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))
const shouldTrigger =
props.initialUrl === "/" && tabs.store.length === 0 && server.list.every(ServerConnection.builtin)
props.initialUrl === "/" &&
route().type === "home" &&
tabs.store.length === 0 &&
server.list.every(ServerConnection.builtin)
console.info("[desktop-onboarding] first launch onboarding evaluated", {
pending,
pending: props.pending,
shouldTrigger,
initialUrl: props.initialUrl,
tabs: tabs.store.length,
@@ -39,11 +46,18 @@ export function DesktopFirstLaunchOnboarding(props: {
const projects = server.projects.forServer(props.serverKey)
projects.open(directory)
projects.touch(directory)
const connection = server.list.find((connection) => ServerConnection.key(connection) === props.serverKey)
if (connection) {
const data = global.ensureServerCtx(connection).data
// Load the initial provider/model state before the draft transition exposes the composer.
await Promise.all([data.location.provider.sync({ directory }), data.location.model.sync({ directory })])
}
tabs.select(await tabs.newDraft({ server: props.serverKey, directory }))
} catch (error) {
console.error("[desktop-onboarding] first launch onboarding failed", error)
} finally {
props.onReady()
}
}
return null
// Let startup failures reach the app's recovery screen, including its splash boundary.
return <>{completed()}</>
}
@@ -0,0 +1,25 @@
[data-component="first-launch-splash"] path {
/* Outline paths use a normalized length so the drawing scales with the wordmark. */
stroke-dasharray: 1;
animation: first-launch-wordmark-draw 1000ms ease-out both;
}
[data-component="first-launch-splash"][data-drawing="pending"] path {
animation-play-state: paused;
}
@keyframes first-launch-wordmark-draw {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="first-launch-splash"] path {
animation: none;
stroke-dasharray: none;
}
}
@@ -1,15 +1,87 @@
import { Splash } from "@opencode/ui/logo"
import { Wordmark } from "@opencode/ui/wordmark"
import type { Platform } from "@opencode/app/desktop"
import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import "./splash.css"
export function LoadingSplash(props: {
deep: boolean
firstLaunch: boolean
platform: Platform
preview: boolean
onDrawEnd: () => void
}) {
const [drawing, setDrawing] = createStore({ started: false })
onMount(() => {
if (!props.preview || !props.firstLaunch) return
const motion = window.matchMedia("(prefers-reduced-motion: reduce)")
const start = () => {
if (document.visibilityState !== "visible" || !document.hasFocus()) return
setDrawing("started", true)
if (motion.matches) props.onDrawEnd()
}
window.addEventListener("focus", start)
document.addEventListener("visibilitychange", start)
motion.addEventListener("change", start)
start()
onCleanup(() => {
window.removeEventListener("focus", start)
document.removeEventListener("visibilitychange", start)
motion.removeEventListener("change", start)
})
})
const titlebarHeight = () => {
const zoom = props.platform.webviewZoom?.() ?? 1
if (props.platform.os === "macos") return `max(36px, ${36 / zoom}px)`
if (props.platform.os === "windows")
return `max(36px, env(titlebar-area-height, ${44 / Math.min(Math.max(zoom, 0.25), 1)}px))`
return "36px"
}
export function LoadingSplash(props: { deep: boolean }) {
return (
<div
class="h-dvh w-screen flex flex-col items-center justify-center"
classList={{
"bg-v2-background-bg-deep": props.deep,
"bg-v2-background-bg-base": !props.deep,
}}
<Show
when={props.firstLaunch}
fallback={
<div
data-component="startup-splash"
class="h-dvh w-screen flex flex-col items-center justify-center"
classList={{
"bg-v2-background-bg-deep": props.deep,
"bg-v2-background-bg-base": !props.deep,
}}
>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
<div
data-component="first-launch-splash"
data-drawing={props.preview && !drawing.started ? "pending" : "running"}
aria-hidden="true"
class="h-dvh w-screen bg-v2-background-bg-base"
style={{
// Match the default session's titlebar, panel insets, and wordmark position.
"padding-block-start": `calc(${titlebarHeight()} + ${props.platform.os === "windows" ? 1 : 8}px)`,
"padding-block-end": "8px",
"padding-inline": "32px",
}}
onAnimationEnd={(event) => {
if (event.animationName === "first-launch-wordmark-draw") props.onDrawEnd()
}}
>
<div class="relative size-full">
<div class="absolute inset-x-0 top-[25.375%]">
<Wordmark
outline
fade={false}
muted={false}
class="mx-auto block h-auto w-full max-w-[720px] text-v2-icon-icon-faint opacity-50"
/>
</div>
</div>
</div>
</Show>
)
}
+27
View File
@@ -99,6 +99,31 @@ export interface SessionWebSocketHandshake {
headers: Record<string, string>
}
/**
* Outbound frame about to be written to the Session's socket, after the provider driver has built
* it. Replacing `frame` sends the replacement verbatim; the driver still tracks state from the
* provider's replies, so a rewrite that changes protocol meaning is on the plugin. Experimental.
*/
export interface SessionWebSocketSend {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
/**
* Inbound frame read from the Session's socket, before the provider driver observes it. Replacing
* `frame` hands the replacement to the driver verbatim. Experimental.
*/
export interface SessionWebSocketReceive {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
export interface SessionRetry {
@@ -120,6 +145,8 @@ export interface SessionHooks {
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
+27
View File
@@ -99,6 +99,31 @@ export interface SessionWebSocketHandshake {
headers: Record<string, string>
}
/**
* Outbound frame about to be written to the Session's socket, after the provider driver has built
* it. Replacing `frame` sends the replacement verbatim; the driver still tracks state from the
* provider's replies, so a rewrite that changes protocol meaning is on the plugin. Experimental.
*/
export interface SessionWebSocketSend {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
/**
* Inbound frame read from the Session's socket, before the provider driver observes it. Replacing
* `frame` hands the replacement to the driver verbatim. Experimental.
*/
export interface SessionWebSocketReceive {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
export interface SessionRetry {
@@ -120,6 +145,8 @@ export interface SessionHooks {
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
+63 -8
View File
@@ -6,19 +6,19 @@
"description": "Experimental HttpApi surface for selected instance routes."
},
"paths": {
"/api/status": {
"/api/info": {
"get": {
"tags": ["server"],
"operationId": "server.status",
"operationId": "server.info",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServerStatus",
"description": "ServerInfo",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerStatus"
"$ref": "#/components/schemas/ServerInfo"
}
}
}
@@ -44,8 +44,8 @@
}
}
},
"description": "Return the server identity, connection URLs, and readiness status.",
"summary": "Get server status"
"description": "Return the server identity, connection URLs, paths, and readiness status.",
"summary": "Get server info"
}
},
"/api/location": {
@@ -121,6 +121,51 @@
"summary": "Get location"
}
},
"/api/location/reload": {
"post": {
"tags": ["location"],
"operationId": "location.reload",
"parameters": [],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
"summary": "Reload locations"
}
},
"/api/agent": {
"get": {
"tags": ["agent"],
@@ -16554,7 +16599,7 @@
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServerStatus": {
"ServerInfo": {
"type": "object",
"properties": {
"version": {
@@ -16569,9 +16614,19 @@
"items": {
"type": "string"
}
},
"paths": {
"type": "object",
"properties": {
"tmp": {
"type": "string"
}
},
"required": ["tmp"],
"additionalProperties": false
}
},
"required": ["version", "pid", "urls"],
"required": ["version", "pid", "urls", "paths"],
"additionalProperties": false
},
"ServiceUnavailableErrorEncoded": {
+8 -6
View File
@@ -23,7 +23,7 @@ import { PersistentPtyGroup } from "./groups/persistent-pty.js"
import { ShellGroup } from "./groups/shell.js"
import { ReferenceGroup } from "./groups/reference.js"
import { Authorization } from "./middleware/authorization.js"
import { LocationGroup } from "./groups/location.js"
import { makeLocationGroup } from "./groups/location.js"
import { IntegrationGroup } from "./groups/integration.js"
import { WebSearchGroup } from "./groups/websearch.js"
import { McpGroup } from "./groups/mcp.js"
@@ -35,7 +35,6 @@ import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof AgentGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof PluginGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ModelGroup, LocationId>
@@ -60,15 +59,17 @@ type SessionGroups<
FormLocationId extends HttpApiMiddleware.AnyId,
FormLocationService,
> =
| ReturnType<
typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
>
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>>
| typeof MessageGroup
type FormGroups<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeFormGroup<LocationId, LocationService>
>
type LocationGroup<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeLocationGroup<LocationId, LocationService>
>
type MixedMiddlewareGroups<
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
@@ -93,6 +94,7 @@ type ApiGroups<
| typeof PersistentPtyGroup
| typeof CredentialGroup
| LocationGroups<LocationId>
| LocationGroup<LocationId, LocationService>
| FormGroups<LocationId, LocationService>
| SessionGroups<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
| MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
@@ -152,7 +154,7 @@ const makeApiFromGroup = <
> =>
HttpApi.make("server")
.add(ServerGroup)
.add(LocationGroup.middleware(locationMiddleware))
.add(makeLocationGroup(locationMiddleware))
.add(AgentGroup.middleware(locationMiddleware))
.add(PluginGroup.middleware(locationMiddleware))
.add(makeSessionGroup(sessionLocationMiddleware, formLocationMiddleware))
+35 -15
View File
@@ -1,6 +1,7 @@
import { Location } from "@opencode/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../errors.js"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
@@ -25,19 +26,38 @@ export const locationQueryOpenApi = OpenApi.annotations({
},
})
export const LocationGroup = HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.PublicInfo,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
// Middleware is applied per endpoint: reload acts on every loaded location and
// must not boot the caller's location first.
export const makeLocationGroup = <LocationId extends HttpApiMiddleware.AnyId, LocationService>(
locationMiddleware: Context.Key<LocationId, LocationService>,
) =>
HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.PublicInfo,
})
.middleware(locationMiddleware)
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
),
)
.add(
HttpApiEndpoint.post("location.reload", "/api/location/reload", {
success: HttpApiSchema.NoContent,
error: ServiceUnavailableError,
}).annotateMerge(
OpenApi.annotations({
identifier: "location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
identifier: "location.reload",
summary: "Reload locations",
description:
"Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "location" }))
)
.annotateMerge(OpenApi.annotations({ title: "location" }))
+11 -8
View File
@@ -1,23 +1,26 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const ServerStatus = Schema.Struct({
export const ServerInfo = Schema.Struct({
version: Schema.String,
// 0 means the runtime has no OS process identity (e.g. workerd).
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
urls: Schema.Array(Schema.String),
}).annotate({ identifier: "ServerStatus" })
export type ServerStatus = typeof ServerStatus.Type
paths: Schema.Struct({
tmp: Schema.String,
}),
}).annotate({ identifier: "ServerInfo" })
export type ServerInfo = typeof ServerInfo.Type
export const ServerGroup = HttpApiGroup.make("server.server")
.add(
HttpApiEndpoint.get("server.status", "/api/status", {
success: ServerStatus,
HttpApiEndpoint.get("server.info", "/api/info", {
success: ServerInfo,
}).annotateMerge(
OpenApi.annotations({
identifier: "server.status",
summary: "Get server status",
description: "Return the server identity, connection URLs, and readiness status.",
identifier: "server.info",
summary: "Get server info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
}),
),
)
+2
View File
@@ -14,6 +14,7 @@ import { InstallationEvent } from "./installation-event.js"
import { Integration } from "./integration.js"
import { LegacyEventV1 } from "./legacy-event.js"
import { LspEvent } from "./lsp-event.js"
import { LocationEvent } from "./location-event.js"
import { McpEvent } from "./mcp-event.js"
import { Model } from "./model.js"
import { ModelsDev } from "./models-dev.js"
@@ -40,6 +41,7 @@ import { WebSearch } from "./websearch.js"
const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
const foundationDefinitions = Event.inventory(
...LocationEvent.Definitions,
...ModelsDev.Event.Definitions,
...Credential.Event.Definitions,
...Integration.Event.Definitions,
+8
View File
@@ -0,0 +1,8 @@
export * as LocationEvent from "./location-event.js"
import { ephemeral, inventory } from "./event.js"
/** The location's cached services were shut down; clients must revalidate its reads. */
export const Shutdown = ephemeral({ type: "location.shutdown", schema: {} })
export const Definitions = inventory(Shutdown)
+1 -1
View File
@@ -98,7 +98,7 @@ export class OpenCodeDO {
async fetch() {
const opencode = await this.opencode
return Response.json(await opencode.server.status())
return Response.json(await opencode.server.info())
}
}
```
+1 -1
View File
@@ -150,7 +150,7 @@ export class OpenCodeDO {
if (this.configurations !== 1 || admitted.some(item => item.payload.text !== "Packed prompt:packed-thread")) {
throw new Error("Packed instance configuration did not share or prepare prompts correctly")
}
return Response.json(await opencode.server.status())
return Response.json(await opencode.server.info())
}
}

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