mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 06:56:16 +00:00
Compare commits
7
Commits
explore-shell
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fad352c5d0 | ||
|
|
e1b94a138e | ||
|
|
9e96aacd64 | ||
|
|
e19263ca87 | ||
|
|
63e33bc4dc | ||
|
|
999097bdac | ||
|
|
515b3022ac |
No files matched your search
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -179,7 +179,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -205,7 +205,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -298,7 +298,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -322,7 +322,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -455,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -553,7 +553,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -572,7 +572,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -585,7 +585,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -599,7 +599,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -614,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -653,7 +653,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -683,7 +683,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -698,7 +698,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -722,7 +722,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -743,7 +743,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -765,7 +765,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -800,7 +800,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -820,7 +820,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -854,7 +854,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -873,7 +873,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -919,7 +919,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -933,7 +933,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -967,7 +967,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1002,7 +1002,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1040,7 +1040,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1081,7 +1081,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -54,6 +54,7 @@ const route = Route.make({
|
||||
),
|
||||
},
|
||||
stream: AnthropicMessages.protocol.stream,
|
||||
supportsEffortUpdates: AnthropicMessages.protocol.supportsEffortUpdates,
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
|
||||
@@ -293,20 +293,28 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
|
||||
it.effect("lowers markers on the Vertex Anthropic route for models that support them", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
const vertex = GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" })
|
||||
const opus5 = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
|
||||
"claude-opus-5",
|
||||
),
|
||||
model: vertex.model("claude-opus-5"),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
const opus48 = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertex.model("claude-opus-4-8"),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(systemMessages(prepared.body)).toHaveLength(0)
|
||||
expect(prepared.body.output_config).toEqual({ effort: "low" })
|
||||
expect(systemMessages(opus5.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
|
||||
expect(opus5.body.output_config).toEqual({ effort: "high" })
|
||||
expect(systemMessages(opus48.body)).toHaveLength(0)
|
||||
expect(opus48.body.output_config).toEqual({ effort: "low" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -650,6 +650,43 @@ test.describe("Working", () => {
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps Working while a new read joins a standalone read group", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
["one", "two", "three"].map((name) =>
|
||||
toolPart(`prt_read_${name}`, "read", "completed", { path: `src/${name}.ts` }),
|
||||
),
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { timelineDetail: timelinePresets[0].value },
|
||||
})
|
||||
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="read-tool-group"]')
|
||||
const shimmer = group.locator('[data-component="text-shimmer"]')
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_read_one,prt_read_two,prt_read_three")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "streaming", { path: "src/four.ts" })))
|
||||
await expect(group).toHaveAttribute(
|
||||
"data-timeline-part-ids",
|
||||
"prt_read_one,prt_read_two,prt_read_three,prt_read_four",
|
||||
)
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "running", { path: "src/four.ts" })))
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("one.ts, two.ts, three.ts, four.ts")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "completed", { path: "src/four.ts" })))
|
||||
await expect(shimmer).toHaveAttribute("data-active", "false")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
|
||||
for (const grouped of [false, true]) {
|
||||
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -374,8 +374,9 @@ function MessageTimelineView(
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
|
||||
// Used groups keep the fallback regardless of disclosure state.
|
||||
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context") return false
|
||||
// Used and read groups keep the fallback, so each new read does not swap Working out for its short call.
|
||||
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context" || row.group.type === "read")
|
||||
return false
|
||||
|
||||
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
|
||||
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions } from "@opencode/client/effect/service"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, Option, Redacted, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
@@ -64,11 +64,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||
const incumbent =
|
||||
const findIncumbent =
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
? Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return
|
||||
if (findIncumbent !== undefined && (yield* findIncumbent) !== undefined) return
|
||||
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
|
||||
if (options.mode === "service" && process.platform === "win32" && RetainedImage.installed(global.home))
|
||||
yield* RetainedImage.retain(global.cache, "service")
|
||||
@@ -88,7 +88,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const instanceID = randomUUID()
|
||||
const transform = yield* WebUi.handler()
|
||||
const server = yield* start(
|
||||
const launch = start(
|
||||
{
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
|
||||
@@ -144,22 +144,29 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
).pipe(
|
||||
)
|
||||
const server = yield* launch.pipe(
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Error(
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||
{ cause: error },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (findIncumbent === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
const found = yield* findIncumbent.pipe(Effect.timeoutOption(deadline - Date.now()))
|
||||
if (Option.isSome(found) && found.value !== undefined) return
|
||||
yield* Effect.sleep("100 millis")
|
||||
if (Date.now() >= deadline) break
|
||||
// Failed binds close their scope; a successful bind may take longer than this window to boot.
|
||||
const server = yield* launch.pipe(Effect.catchIf(addressInUse, () => Effect.void))
|
||||
if (server !== undefined) return server
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||
{ cause: error },
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return
|
||||
@@ -175,15 +182,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
|
||||
Effect.filterOrFail((value) => value !== undefined),
|
||||
Effect.retry(Schedule.spaced("100 millis")),
|
||||
Effect.timeoutOption("15 seconds"),
|
||||
)
|
||||
return Option.isSome(found)
|
||||
})
|
||||
|
||||
function serviceURL(hostname: string, port: number) {
|
||||
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ function isRouteNotFound(error: unknown) {
|
||||
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
|
||||
}
|
||||
|
||||
// qr-scanner decodes in a worker it creates from a blob: URL whenever the browser has no BarcodeDetector (Safari, desktop
|
||||
// Chrome on Windows and Linux). Without worker-src that worker falls under script-src 'self' and never starts.
|
||||
function csp(hash = "") {
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:; worker-src 'self' blob:`
|
||||
}
|
||||
|
||||
function cspForHtml(body: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Schema } from "effect"
|
||||
import { Effect, FileSystem, Schedule, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -403,6 +403,77 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("the original managed service contender binds when the occupied port is released", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-retry-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
const requests: string[] = []
|
||||
using listener = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
requests.push(new URL(request.url).pathname)
|
||||
if (requests.length === 2) recognizing.resolve()
|
||||
return Response.json({ unrelated: true })
|
||||
},
|
||||
})
|
||||
const port = listener.port
|
||||
if (port === undefined) throw new Error("Server did not bind a port")
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config"), { recursive: true })
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(path.join(root, "config", "service-local.json"), JSON.stringify({ port }))
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({
|
||||
id: "stale",
|
||||
version: OPENCODE_VERSION,
|
||||
url: "http://127.0.0.1:1",
|
||||
pid: 2_147_483_647,
|
||||
password: "stale",
|
||||
}),
|
||||
)
|
||||
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: isolatedEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
const stderr = new Response(contender.stderr).text()
|
||||
try {
|
||||
// The first probe is preflight; the second only happens after start() fails to bind.
|
||||
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
|
||||
expect(requests).toEqual(["/api/info", "/api/info"])
|
||||
await listener.stop(true)
|
||||
|
||||
const info = await Promise.race([
|
||||
waitForInfo(registration, (info) => info.pid === contender.pid),
|
||||
contender.exited.then(() => undefined),
|
||||
])
|
||||
expect(info?.pid, contender.exitCode === null ? undefined : await stderr).toBe(contender.pid)
|
||||
const endpoint = await Effect.runPromise(
|
||||
Service.discover({ file: registration }).pipe(
|
||||
Effect.filterOrFail((value) => value !== undefined),
|
||||
Effect.retry({ times: 400, schedule: Schedule.spaced("50 millis") }),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(new URL(endpoint.url).port).toBe(String(port))
|
||||
expect(
|
||||
await fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }).then((response) =>
|
||||
response.json(),
|
||||
),
|
||||
).toMatchObject({ pid: contender.pid, version: OPENCODE_VERSION, urls: [endpoint.url] })
|
||||
expect(contender.exitCode).toBe(null)
|
||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
expect(await waitForExit(contender)).toBe(true)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await expectPortAvailable(port)
|
||||
} finally {
|
||||
contender.kill("SIGTERM")
|
||||
await contender.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 45_000)
|
||||
|
||||
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
|
||||
@@ -171,6 +171,7 @@ describe("web UI", () => {
|
||||
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("worker-src 'self' blob:")
|
||||
|
||||
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
|
||||
expect(dotted.status).toBe(200)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
|
||||
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
@@ -11,8 +9,10 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, type LocalService, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
/** Contents of the local service registration file. */
|
||||
export type Info = import("../service.js").Info
|
||||
|
||||
@@ -40,9 +40,9 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
options: DiscoverOptions & { readonly url: string },
|
||||
) {
|
||||
const info = yield* read(options.file)
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
if (found === undefined) return undefined
|
||||
if (!found.compatible) return undefined
|
||||
if (info === undefined) return undefined
|
||||
const found = (yield* Effect.promise(() => probeResult({ ...info, url: options.url }))).service
|
||||
if (!found?.compatible) return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
@@ -58,6 +58,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let failure: Error | undefined
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||
Effect.sync(() => {
|
||||
if (announced) return
|
||||
@@ -89,6 +90,13 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
yield* terminate(info, options, timing)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -117,19 +125,27 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
failure ??= finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
yield* announce("missing")
|
||||
contenders.add(yield* spawnContender)
|
||||
lastSpawn = Date.now()
|
||||
@@ -145,7 +161,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
|
||||
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
})
|
||||
|
||||
@@ -161,17 +177,6 @@ export const stop = Effect.fn("service.stop")(function* (options: StopOptions =
|
||||
if (info !== undefined) yield* terminate(info, options, ensureTiming(options))
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
|
||||
return join(state, "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
|
||||
}
|
||||
|
||||
/** Schema for the local service registration file. */
|
||||
export const Info = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
@@ -182,13 +187,6 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeInfo = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
version: Schema.String,
|
||||
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}),
|
||||
)
|
||||
|
||||
// A missing or corrupt file means no valid info; callers treat both
|
||||
// the same (the registering server self-evicts, clients rediscover).
|
||||
const read = Effect.fnUntraced(function* (file?: string) {
|
||||
@@ -198,77 +196,10 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
})
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info) {
|
||||
return (yield* probeResult(info)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready" as const,
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const body = result.value.body
|
||||
const serverInfo = decodeInfo(body)
|
||||
if (Option.isSome(serverInfo)) {
|
||||
if (serverInfo.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.value.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, timeout?: number) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* probeResult(info, timeout)) }
|
||||
return { info, ...(yield* Effect.promise(() => probeResult(info, timeout))) }
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
@@ -287,10 +218,6 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
@@ -11,8 +9,10 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
|
||||
// Find, start, and stop the local opencode background service.
|
||||
//
|
||||
@@ -38,6 +38,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let failure: Error | undefined
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
@@ -56,7 +57,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
if (Date.now() >= deadline) throw failure ?? new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
@@ -68,6 +69,13 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
await terminate(registration.info, options, timing)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === registration.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -95,19 +103,27 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).catch(() => undefined)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) throw failure
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
contenders.add(await spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
@@ -132,18 +148,6 @@ export async function stop(options: StopOptions = {}) {
|
||||
if (info !== undefined) await terminate(info, options, ensureTiming(options))
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return {
|
||||
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||
}
|
||||
}
|
||||
|
||||
async function read(file?: string) {
|
||||
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
|
||||
if (text === undefined) return undefined
|
||||
@@ -154,73 +158,6 @@ async function read(file?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready" as const,
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const serverInfo = decodeInfo(result.value.body)
|
||||
if (serverInfo !== undefined) {
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
}
|
||||
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
return { version: input.version, pid: input.pid }
|
||||
}
|
||||
|
||||
async function registered(file?: string, timeout?: number) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
@@ -250,10 +187,6 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
|
||||
return false
|
||||
}
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Endpoint, Info } from "./service.js"
|
||||
import { defaultEnsureTiming } from "./service-timing.js"
|
||||
|
||||
// Shared by the Effect and Promise clients. Keep this module free of Effect so the
|
||||
// Promise client never loads the Effect runtime.
|
||||
|
||||
export type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
/** The default registration file. */
|
||||
export function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Whether two registrations describe the same service instance. */
|
||||
export function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
/** Ask a registered owner for its health and classify the answer. */
|
||||
export async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth: info.password === undefined ? undefined : { type: "basic", username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.catch(() => undefined)
|
||||
if (result === undefined) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready",
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const serverInfo = decodeInfo(result.body)
|
||||
if (serverInfo === undefined) return { service: undefined, timedOut: false }
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return {
|
||||
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
return { version: input.version, pid: input.pid }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { mkdtemp, rename, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { waitForExit } from "./service-timing"
|
||||
@@ -8,6 +8,16 @@ export async function serviceFixture() {
|
||||
const registration = join(directory, "service.json")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const pids = new Set<number>()
|
||||
const starts = async () =>
|
||||
(
|
||||
await Bun.file(registration + ".starts")
|
||||
.text()
|
||||
.catch(() => "")
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
const command = (mode: string, ...args: string[]) => [
|
||||
process.execPath,
|
||||
join(import.meta.dir, "service.ts"),
|
||||
@@ -20,6 +30,21 @@ export async function serviceFixture() {
|
||||
directory,
|
||||
registration,
|
||||
command,
|
||||
starts,
|
||||
async waitForStarts(count: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
const result = await starts()
|
||||
if (result.length >= count) return result
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} contenders`)
|
||||
},
|
||||
async release(pid: number, action: "fail" | "hang" | "ready") {
|
||||
const file = registration + `.release-${pid}`
|
||||
await Bun.write(file + ".tmp", action)
|
||||
await rename(file + ".tmp", file)
|
||||
if (action === "fail") await waitForExit(pid)
|
||||
},
|
||||
spawn(mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn(command(mode, ...args), { stdout: "ignore", stderr: "inherit" })
|
||||
processes.push(subprocess)
|
||||
@@ -47,6 +72,8 @@ export async function serviceFixture() {
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
// Include detached contenders that never registered, even when an assertion fails.
|
||||
for (const pid of await starts()) pids.add(pid)
|
||||
await Promise.all([
|
||||
...processes.map(async (subprocess) => {
|
||||
subprocess.kill("SIGTERM")
|
||||
|
||||
@@ -17,6 +17,18 @@ if (mode === "environment") {
|
||||
}
|
||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||
|
||||
let controlled = ""
|
||||
if (mode === "controlled") {
|
||||
await appendFile(registration + ".starts", process.pid + "\n")
|
||||
const release = registration + `.release-${process.pid}`
|
||||
while (!(await Bun.file(release).exists())) await Bun.sleep(5)
|
||||
controlled = await Bun.file(release).text()
|
||||
if (controlled === "fail") {
|
||||
process.stderr.write("actionable startup failure: storage initialization denied\n")
|
||||
process.exit(23)
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||
await appendFile(registration + ".starts", process.pid + "\n")
|
||||
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
|
||||
@@ -63,7 +75,7 @@ const server = Bun.serve({
|
||||
return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".status-request", "")
|
||||
if (mode === "hanging") {
|
||||
if (mode === "hanging" || controlled === "hang") {
|
||||
await appendFile(registration + ".requests", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
}
|
||||
@@ -92,8 +104,10 @@ const server = Bun.serve({
|
||||
})
|
||||
|
||||
// Install handlers before publishing: a test may signal as soon as the registration appears.
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
if (controlled !== "hang") {
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
registration + ".tmp",
|
||||
|
||||
@@ -112,6 +112,76 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("reports overlapping contender failures without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("retains a contender failure until the deadline while its survivor stalls", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
expect(() => process.kill(second, 0)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "ready")
|
||||
const endpoint = await pending
|
||||
|
||||
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(second, "hang")
|
||||
await fixture.waitForFile()
|
||||
await fixture.release(first, "fail")
|
||||
const [, , third] = await fixture.waitForStarts(3)
|
||||
await fixture.release(third, "ready")
|
||||
const endpoint = await pending
|
||||
fixture.track(third)
|
||||
|
||||
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail with native promises", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
|
||||
@@ -290,6 +290,76 @@ test("reports a contender that fails to start", async () => {
|
||||
).rejects.toThrow("Server process exited with code 1")
|
||||
})
|
||||
|
||||
test("reports overlapping contender failures without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = run(
|
||||
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
|
||||
).catch((error: unknown) => error)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("retains a contender failure until the deadline while its survivor stalls", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = run(
|
||||
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
|
||||
).catch((error: unknown) => error)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
expect(() => process.kill(second, 0)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "ready")
|
||||
const endpoint = await pending
|
||||
|
||||
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(second, "hang")
|
||||
await fixture.waitForFile()
|
||||
await fixture.release(first, "fail")
|
||||
const [, , third] = await fixture.waitForStarts(3)
|
||||
await fixture.release(third, "ready")
|
||||
const endpoint = await pending
|
||||
fixture.track(third)
|
||||
|
||||
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -7,13 +7,19 @@ import { Permission } from "../permission.js"
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
Your strengths:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
|
||||
Guidelines:
|
||||
- Your role is EXCLUSIVELY to search and analyze
|
||||
- Parallelize independent tool calls for searches and reads whenever possible
|
||||
- Use Glob for broad file pattern matching
|
||||
- Use Grep for searching file contents with regex
|
||||
- Use Read when you know the specific file path you need to read
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- You MUST NOT create, modify, delete, move, or copy files, including temporary files and reports
|
||||
- Shell commands MUST be read-only. NEVER run commands that write files or change system state
|
||||
- For clear communication, avoid using emojis
|
||||
- Do not create any files, or run bash commands that modify the user's system state in any way
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
|
||||
@@ -109,7 +115,6 @@ export const Plugin = define({
|
||||
...Permission.merge(
|
||||
[
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
{ action: "glob", resource: "*", effect: "allow" },
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
|
||||
@@ -203,8 +203,6 @@ describe("Agent", () => {
|
||||
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
|
||||
const explore = yield* agent.get(Agent.ID.make("explore"))
|
||||
expect(Permission.evaluate("shell", "git log -5", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("edit", "src/index.ts", explore?.permissions ?? []).effect).toBe("deny")
|
||||
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.22",
|
||||
"version": "2.0.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in new issue
Block a user