Compare commits

...
Author SHA1 Message Date
Dax Raad 08e4d4fe0c serve: early-return for socket, skip network options resolve 2026-05-16 02:28:34 -04:00
Dax Raad 342d02aefe serve: inline listener url creation 2026-05-16 01:06:38 -04:00
Dax Raad 12c19d627d serve: expose socket listener url 2026-05-16 01:01:53 -04:00
Dax Raad 41f0f7d407 serve: drop socket listener url 2026-05-16 01:01:03 -04:00
Dax Raad 91d148d95f serve: avoid fake socket listener port 2026-05-16 01:00:02 -04:00
Dax Raad 3caba56bd9 serve: fold mdns domain into option 2026-05-16 00:51:18 -04:00
Dax Raad cb45de6fd2 serve: inline node listen options 2026-05-16 00:49:27 -04:00
Dax Raad d1f44d4cd2 serve: require explicit tcp listener type 2026-05-16 00:49:19 -04:00
Dax Raad 25400f48df serve: keep resolved listen options together 2026-05-16 00:48:00 -04:00
Dax Raad a2008478f9 serve: type listener transports explicitly 2026-05-16 00:43:56 -04:00
Dax Raad 971fe35e81 serve: keep socket listener option-driven 2026-05-16 00:41:40 -04:00
Dax Raad e3cbfa1c05 serve: simplify socket listener setup 2026-05-16 00:40:19 -04:00
Dax Raad 2719140b56 serve: add socket listener mode 2026-05-16 00:23:09 -04:00
Dax Raad 042e6a5c86 tui: newly pinned sessions now append to the end of the list instead of jumping to the top 2026-05-15 23:57:40 -04:00
Dax Raad e36d6a0cbe core: clarify postinstall error message when binary is missing
Users installing with pnpm or --ignore-scripts now see a clear explanation
of why the native binary is missing and exact steps to fix it. The shell
script wrapper provides more actionable guidance than the previous Node.js
fallback that only suggested running the postinstall script without context
about why it wasn't run automatically.
2026-05-15 23:12:00 -04:00
Sebastian cc9c0b15c7 Revert "add dialog prompt submit keybind (#27807)" (#27815) 2026-05-16 04:44:32 +02:00
Kit Langton f3b0d3d7ac fix(tui): dedupe consecutive prompt history entries (#27816) 2026-05-16 02:36:35 +00:00
opencode-agent[bot] 764c6bc517 chore: update nix node_modules hashes 2026-05-16 01:20:56 +00:00
Sebastian d441e931f9 add dialog prompt submit keybind (#27807) 2026-05-16 03:11:46 +02:00
Sebastian ad79ad9ea8 upgrade opentui to 0.2.11 (#27808) 2026-05-16 03:05:54 +02:00
opencode-agent[bot] d6b23fd8f6 chore: generate 2026-05-16 00:44:10 +00:00
Kit Langton 5911bd532d fix(tui): show config error details on startup (#27803) 2026-05-15 20:42:56 -04:00
Dax Raad 2385123f03 Fix thinking toggle defaults 2026-05-15 20:27:36 -04:00
26 changed files with 435 additions and 116 deletions
+15 -15
View File
@@ -536,9 +536,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.10", "@opentui/core": ">=0.2.11",
"@opentui/keymap": ">=0.2.10", "@opentui/keymap": ">=0.2.11",
"@opentui/solid": ">=0.2.10", "@opentui/solid": ">=0.2.11",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -721,9 +721,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.10", "@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.10", "@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.10", "@opentui/solid": "0.2.11",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -1590,23 +1590,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="], "@opentui/core": ["@opentui/core@0.2.11", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.11", "@opentui/core-darwin-x64": "0.2.11", "@opentui/core-linux-arm64": "0.2.11", "@opentui/core-linux-x64": "0.2.11", "@opentui/core-win32-arm64": "0.2.11", "@opentui/core-win32-x64": "0.2.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-X0zLmcDEvMrPzWYp769I7VEVb+og38vaete9tGZXu9HnJgu/paPUUplUT+6denBQccr2qx1rBYV6EtgbBpLEyw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h2MXtE2Cu3XlKVoQMXthnbhleO68zGXkoh/r1Q5pCoZh6RuXqns5/94D/aZThXBWwzPuEoyarMlxxR9OqrpvHw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y0jbPClnOBTPSIy+2THG86MTqIG/jGFlOOKuw4JfCDqEjPBM3pLWIHnJb3WxHRi2LlvfyBxvrUTXWlW6JpI0QQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-blQyyuTaW4q/OQ3whs7Kt7GCXhBUR5EQHHDdjOqQAr0HYpohUa6sbHMbiBcX2Ehc9ZWwtiaOoWiyZ5YXy2SAvg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-0nEB5+MgzQRYiVcQd1vHXPWNPWGh4JEmQTJKyG3OHnTzPaJ1FVSQ/V71ECyRSl3ymY3F+U0eW9cFgw1hCieK2w=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-+KKH77fzm0qF8py9G2pU32DzB1bAgDMfBajrs7gKL5NtSEnknrwfh7hIs/tq41aF6j9zvIzgtykByh26tcjFog=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-dMmb9DX0W0HWadLdgciMbonqIc1xdcKiVmaQSYxw5eGCzFRPZIOrKHByesP+2ipkMuLx85W/MJUFal/lW8XSNg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="], "@opentui/keymap": ["@opentui/keymap@0.2.11", "", { "dependencies": { "@opentui/core": "0.2.11" }, "peerDependencies": { "@opentui/react": "0.2.11", "@opentui/solid": "0.2.11", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-pCrJrY3mTuXdDaaRneId1JsJCtGE+7prTtWihzOLZzVJTJYyYtT38gMI7MpyAoloVDfEL5cTe8C+v7wv+IYREw=="],
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="], "@opentui/solid": ["@opentui/solid@0.2.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M3WHxBFORHVE0yqMJYpi9PfjXWlnRTw/LYuBhZaJv0HTo+zTs60P/ukGcwnHDWnMpTGf3BH9x0Yi2dIqjHRY6Q=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-Hw7sVV9rTm6qBMtdwfLIV2QvxvLQY5qrywXzuyYbhcs=", "x86_64-linux": "sha256-Ucvyzyq+oYvWglkeowSvb0LgDzkAvaSdq0CdA6jgN6U=",
"aarch64-linux": "sha256-++oXnY7YqrYt0Qv7ZISmoHliARM9qEP8FacqLxGZH1c=", "aarch64-linux": "sha256-SERwZvvN6P8/OwNolHmC0KU9H5laVQm+FD/NNKauZA8=",
"aarch64-darwin": "sha256-kZVa0R1YbuvtTzpETqK6ddj4ISje5jBFHBdlynkhW7Q=", "aarch64-darwin": "sha256-I1ABwMHkTAntlYyg43w0cW8iPYfZa9MT0In2C7plB5g=",
"x86_64-darwin": "sha256-94eagNDa8GGJxF8BsMX2BF5Pa+QTl48lXL1+6HgEn0I=" "x86_64-darwin": "sha256-degJTL0RG7QQO8/USgIF//ya7oNmwChTmAoJcpXbIp0="
} }
} }
+3 -3
View File
@@ -35,9 +35,9 @@
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.10", "@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.10", "@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.10", "@opentui/solid": "0.2.11",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
-1
View File
@@ -38,7 +38,6 @@ export const Flag = {
), ),
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DB: process.env["OPENCODE_DB"], OPENCODE_DB: process.env["OPENCODE_DB"],
+10 -3
View File
@@ -37,9 +37,16 @@ await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()) await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write( await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write(
[ [
"#!/usr/bin/env node", `echo "Error: ${pkg.name}-ai's postinstall script was not run." >&2`,
"console.error('The opencode native binary was not installed. Run `node postinstall.mjs` from the opencode-ai package directory to finish setup.')", 'echo "" >&2',
"process.exit(1)", 'echo "This occurs when using --ignore-scripts during installation, or when using a" >&2',
'echo "package manager like pnpm that does not run postinstall scripts by default." >&2',
'echo "" >&2',
'echo "To fix this, run the postinstall script manually:" >&2',
`echo " cd node_modules/${pkg.name}-ai && node postinstall.mjs" >&2`,
'echo "" >&2',
`echo "Or reinstall ${pkg.name}-ai without the --ignore-scripts flag." >&2`,
"exit 1",
"", "",
].join("\n"), ].join("\n"),
) )
+84
View File
@@ -70,11 +70,54 @@ Endpoint definitions declare which public errors can be emitted. Public
HTTP error schemas carry their response status with `httpApiStatus` or the HTTP error schemas carry their response status with `httpApiStatus` or the
equivalent HttpApi schema annotation. equivalent HttpApi schema annotation.
Effect's own HttpApi examples follow this pattern:
```ts
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class Authorization extends HttpApiMiddleware.Service<
Authorization,
{
provides: CurrentUser
}
>()("app/Authorization", {
security: { bearer: HttpApiSecurity.bearer },
error: Unauthorized,
}) {}
```
Endpoint-level errors use the same idea:
```ts
export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
{
name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
},
{ httpApiStatus: 400 },
) {}
HttpApiEndpoint.get("get", "/config", {
success: Config.Info,
error: ConfigApiError,
})
```
The service error and HTTP error may be the same class only when the wire The service error and HTTP error may be the same class only when the wire
shape is intentionally public. Use separate HTTP error schemas when the shape is intentionally public. Use separate HTTP error schemas when the
service error contains internals, low-level causes, retry hints, or data service error contains internals, low-level causes, retry hints, or data
that should not be exposed to API clients. that should not be exposed to API clients.
Do not map every domain error into one universal HTTP error class. Prefer a
small public error vocabulary by route group: shared shapes like
`ApiNotFoundError`, route-specific shapes like `ConfigApiError`, and built-in
empty `HttpApiError.*` only when an empty/no-content body is the intended SDK
contract.
## Mapping Guidance ## Mapping Guidance
- Keep one-off translations inline in the handler. - Keep one-off translations inline in the handler.
@@ -86,6 +129,35 @@ that should not be exposed to API clients.
breaking API change. breaking API change.
- Use built-in `HttpApiError.*` only when its generated body and SDK - Use built-in `HttpApiError.*` only when its generated body and SDK
surface are intentionally the public contract. surface are intentionally the public contract.
- Prefer `Schema.ErrorClass` for public HTTP error bodies whose wire shape is
not the same as the internal domain error shape.
- Prefer `Schema.TaggedErrorClass` for service/domain errors and middleware
errors that are naturally tagged by `_tag`.
- If preserving a legacy `{ name, data }` body, model that shape explicitly in
the public API error schema instead of relying on `NamedError.toObject()` in
generic middleware.
## User-Facing Rendering
HTTP serialization and user rendering are separate boundaries. The server
should send structured public errors; CLI and TUI code should format those
structures through one shared formatter.
For SDK calls using `{ throwOnError: true }`, the generated client may wrap the
decoded response body in an `Error`. The original body should remain available
under `error.cause.body`; `FormatError` is the right place to unwrap and render
that body. TUI aggregation helpers should call `FormatError` first, then fall
back to generic `Error.message` / string rendering.
When several parallel startup requests fail from the same underlying issue,
group identical rendered messages and list the affected request names once.
For example:
```text
Configuration is invalid at /path/to/opencode.json
↳ Expected object, got "not-object" provider.bad.options
Affected startup requests: config.providers, provider.list, app.agents, config.get
```
## Middleware Guidance ## Middleware Guidance
@@ -99,6 +171,15 @@ middleware should shrink. It should not gain new name checks.
Unknown `500` responses should log full details server-side with Unknown `500` responses should log full details server-side with
`Cause.pretty(cause)` and return a safe public body. `Cause.pretty(cause)` and return a safe public body.
The config startup regression in #27056 is the failure mode this rule is meant
to avoid: a user-authored invalid `opencode.json` crossed the HttpApi boundary
as a defect, so middleware replaced a useful `ConfigInvalidError` with a safe
generic `UnknownError`. The compatibility fix is to preserve config parse and
validation errors as client-visible `400`s. The target architecture is better:
config loading should fail on the typed error channel, config HTTP handlers
should map those errors to declared `ConfigApiError` responses, and the generic
middleware should never see them.
## Migration Order ## Migration Order
Prefer small vertical slices: Prefer small vertical slices:
@@ -113,6 +194,9 @@ Prefer small vertical slices:
Good early domains are storage not-found, worktree errors, and provider Good early domains are storage not-found, worktree errors, and provider
auth validation errors because they currently drive HTTP behavior. auth validation errors because they currently drive HTTP behavior.
Config parse and validation errors are also a good early slice because they
are startup-blocking and must be rendered clearly in both CLI and TUI flows.
## Checklist For A PR ## Checklist For A PR
- [ ] Expected failures are typed errors, not defects. - [ ] Expected failures are typed errors, not defects.
+27 -3
View File
@@ -6,15 +6,26 @@ import { Flag } from "@opencode-ai/core/flag/flag"
export const ServeCommand = effectCmd({ export const ServeCommand = effectCmd({
command: "serve", command: "serve",
builder: (yargs) => withNetworkOptions(yargs), builder: (yargs) =>
withNetworkOptions(yargs)
.option("socket", {
type: "string",
describe: "Unix socket path or Windows named pipe name/path to listen on",
}),
describe: "starts a headless opencode server", describe: "starts a headless opencode server",
// Server loads instances per-request via x-opencode-directory header — no
// need for an ambient project InstanceContext at startup.
instance: false, instance: false,
handler: Effect.fn("Cli.serve")(function* (args) { handler: Effect.fn("Cli.serve")(function* (args) {
if (!Flag.OPENCODE_SERVER_PASSWORD) { if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
} }
if (args.socket) {
const server = yield* Effect.promise(() =>
Server.listen({ type: "socket", socket: resolveSocketPath(args.socket) }),
)
console.log(`opencode server listening on socket ${server.socket}`)
yield* Effect.never
}
const opts = yield* resolveNetworkOptions(args) const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts)) const server = yield* Effect.promise(() => Server.listen(opts))
console.log(`opencode server listening on http://${server.hostname}:${server.port}`) console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
@@ -22,3 +33,16 @@ export const ServeCommand = effectCmd({
yield* Effect.never yield* Effect.never
}), }),
}) })
function resolveSocketPath(input: string) {
if (process.platform !== "win32") return input
const lower = input.toLowerCase()
if (lower.startsWith("\\\\.\\pipe\\") || lower.startsWith("\\\\?\\pipe\\")) return input
const name = input
.replace(/^[a-zA-Z]:/, (drive) => drive.slice(0, 1))
.replace(/[\\/:]+/g, "-")
.replace(/^-+|-+$/g, "")
return `\\\\.\\pipe\\${name || "opencode"}`
}
@@ -27,6 +27,11 @@ export type PromptInfo = {
const MAX_HISTORY_ENTRIES = 50 const MAX_HISTORY_ENTRIES = 50
export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptInfo): boolean {
if (!previous) return false
return JSON.stringify(previous) === JSON.stringify(next)
}
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory", name: "PromptHistory",
init: () => { init: () => {
@@ -83,6 +88,10 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
}, },
append(item: PromptInfo) { append(item: PromptInfo) {
const entry = structuredClone(unwrap(item)) const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(store.history.at(-1), entry)) {
setStore("index", 0)
return
}
let trimmed = false let trimmed = false
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -1,3 +1,5 @@
import { FormatError } from "@/cli/error"
/** /**
* Aggregate Promise.allSettled results into a single Error that names every * Aggregate Promise.allSettled results into a single Error that names every
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
@@ -15,7 +17,19 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
) )
if (failed.length === 0) return null if (failed.length === 0) return null
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") const reasons = Array.from(
failed
.map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) }))
.reduce((grouped, failure) => {
grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name])
return grouped
}, new Map<string, string[]>())
.entries(),
)
.map(([message, names]) =>
names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`,
)
.join("; ")
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
const err = new Error(summary) const err = new Error(summary)
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
@@ -23,6 +37,9 @@ export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
} }
function reasonMessage(reason: unknown): string { function reasonMessage(reason: unknown): string {
const formatted = FormatError(reason)
if (formatted) return formatted
if (reason instanceof Error) return reason.message if (reason instanceof Error) return reason.message
if (typeof reason === "string") return reason if (typeof reason === "string") return reason
if (reason && typeof reason === "object") { if (reason && typeof reason === "object") {
@@ -457,7 +457,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const exists = sessionStore.pinned.includes(sessionID) const exists = sessionStore.pinned.includes(sessionID)
const next = exists const next = exists
? sessionStore.pinned.filter((x) => x !== sessionID) ? sessionStore.pinned.filter((x) => x !== sessionID)
: [sessionID, ...sessionStore.pinned] : [...sessionStore.pinned, sessionID]
setSessionStore("pinned", next) setSessionStore("pinned", next)
save() save()
}) })
@@ -1,10 +1,9 @@
import { createMemo, type Setter } from "solid-js" import { createMemo, type Setter } from "solid-js"
import { Flag } from "@opencode-ai/core/flag/flag"
import { useKV } from "./kv" import { useKV } from "./kv"
export type ThinkingMode = "show" | "minimal" | "hide" export type ThinkingMode = "show" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "minimal", "hide"] as const const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded // OpenAI's Responses API surfaces reasoning summaries that start with a bolded
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes // title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
@@ -20,7 +19,7 @@ export function isThinkingMode(value: unknown): value is ThinkingMode {
return typeof value === "string" && (MODES as readonly string[]).includes(value) return typeof value === "string" && (MODES as readonly string[]).includes(value)
} }
// Cycle order matches the slash command: show → minimal → hide → show. // Cycle order matches the slash command: show → hide → show.
export function nextThinkingMode(current: ThinkingMode): ThinkingMode { export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
const idx = MODES.indexOf(current) const idx = MODES.indexOf(current)
return MODES[(idx + 1) % MODES.length] ?? "show" return MODES[(idx + 1) % MODES.length] ?? "show"
@@ -33,7 +32,7 @@ export function useThinkingMode() {
// The KVProvider only renders children once kv.ready, so reads here are safe. // The KVProvider only renders children once kv.ready, so reads here are safe.
const hadStored = kv.get("thinking_mode") !== undefined const hadStored = kv.get("thinking_mode") !== undefined
const legacy = kv.get("thinking_visibility") const legacy = kv.get("thinking_visibility")
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "minimal") const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's // The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
// overload set; passing an updater fn through a property access loses the // overload set; passing an updater fn through a property access loses the
@@ -47,21 +46,21 @@ export function useThinkingMode() {
// Preserve previous experience for users who had explicitly toggled the // Preserve previous experience for users who had explicitly toggled the
// legacy `thinking_visibility` boolean. First-time users (no legacy key) // legacy `thinking_visibility` boolean. First-time users (no legacy key)
// get the new "minimal" default. // get the new "hide" default (collapsed thinking).
if (!hadStored) { if (!hadStored) {
if (legacy === true) set("show") if (legacy === true) set("show")
else if (legacy === false) set("hide") else if (legacy === false) set("hide")
} }
if ((stored() as string) === "minimal") set("hide")
const mode = createMemo<ThinkingMode>(() => { const mode = createMemo<ThinkingMode>(() => {
if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal"
const value = stored() const value = stored()
return isThinkingMode(value) ? value : "minimal" return isThinkingMode(value) ? value : "hide"
}) })
return { return {
mode, mode,
set, set,
locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true,
} }
} }
@@ -392,7 +392,7 @@ function AssistantReasoning(props: {
const thinking = useThinkingMode() const thinking = useThinkingMode()
const [expanded, setExpanded] = createSignal(false) const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
const inMinimal = createMemo(() => thinking.mode() === "minimal") const inMinimal = createMemo(() => thinking.mode() === "hide")
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning // v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
// in the v2 SDK); we settle on parent-message completion instead. // in the v2 SDK); we settle on parent-message completion instead.
const isDone = createMemo(() => props.completedAt() !== undefined) const isDone = createMemo(() => props.completedAt() !== undefined)
@@ -404,7 +404,7 @@ function AssistantReasoning(props: {
} }
return ( return (
<Show when={content() && thinking.mode() !== "hide"}> <Show when={content()}>
<Switch> <Switch>
<Match when={!inMinimal() || expanded()}> <Match when={!inMinimal() || expanded()}>
<box <box
@@ -218,7 +218,7 @@ export function Session() {
const [conceal, setConceal] = createSignal(true) const [conceal, setConceal] = createSignal(true)
const thinking = useThinkingMode() const thinking = useThinkingMode()
const thinkingMode = thinking.mode const thinkingMode = thinking.mode
const showThinking = createMemo(() => thinkingMode() !== "hide") const showThinking = createMemo(() => true)
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
@@ -689,9 +689,8 @@ export function Session() {
{ {
title: (() => { title: (() => {
const next = nextThinkingMode(thinkingMode()) const next = nextThinkingMode(thinkingMode())
if (next === "minimal") return "Switch thinking to minimal" if (next === "hide") return "Collapse thinking"
if (next === "hide") return "Hide thinking" return "Expand thinking"
return "Show thinking"
})(), })(),
value: "session.toggle.thinking", value: "session.toggle.thinking",
category: "Session", category: "Session",
@@ -700,16 +699,6 @@ export function Session() {
aliases: ["toggle-thinking"], aliases: ["toggle-thinking"],
}, },
run: () => { run: () => {
// Env override forces minimal for the process. Updating KV here would
// silently diverge from what's rendered; tell the user instead.
if (thinking.locked()) {
toast.show({
message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING",
variant: "info",
})
dialog.clear()
return
}
thinking.set(nextThinkingMode(thinkingMode())) thinking.set(nextThinkingMode(thinkingMode()))
dialog.clear() dialog.clear()
}, },
@@ -1512,7 +1501,7 @@ const PART_MAPPING = {
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, subtleSyntax } = useTheme() const { theme, subtleSyntax } = useTheme()
const ctx = use() const ctx = use()
// Collapsed by default in minimal mode: a single line throughout, so the // Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close. // layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false) const [expanded, setExpanded] = createSignal(false)
@@ -1523,7 +1512,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
// Reasoning is finalized when the server sets `time.end` (see processor.ts). // Reasoning is finalized when the server sets `time.end` (see processor.ts).
// Flips independently of the parent message completing. // Flips independently of the parent message completing.
const isDone = createMemo(() => props.part.time.end !== undefined) const isDone = createMemo(() => props.part.time.end !== undefined)
const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal") const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => { const duration = createMemo(() => {
const end = props.part.time.end const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start) return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
@@ -1539,10 +1528,10 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
} }
return ( return (
<Show when={content() && ctx.thinkingMode() !== "hide"}> <Show when={content()}>
<Switch> <Switch>
<Match when={!inMinimal() || expanded()}> <Match when={!inMinimal() || expanded()}>
{/* Full markdown block: `show` mode, or `minimal` after the user opens it. */} {/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
<box <box
id={"text-" + props.part.id} id={"text-" + props.part.id}
paddingLeft={2} paddingLeft={2}
@@ -1558,7 +1547,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
drawUnstyledText={false} drawUnstyledText={false}
streaming={true} streaming={true}
syntaxStyle={subtleSyntax()} syntaxStyle={subtleSyntax()}
content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()} content={(inMinimal() ? "▼ " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
conceal={ctx.conceal()} conceal={ctx.conceal()}
fg={theme.textMuted} fg={theme.textMuted}
/> />
+1 -1
View File
@@ -72,7 +72,7 @@ export const rpc = {
}, },
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true) if (server) await server.stop(true)
server = await Server.listen(input) server = await Server.listen({ type: "tcp", ...input, mdns: input.mdns ? true : undefined })
return { url: server.url.toString() } return { url: server.url.toString() }
}, },
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
+1 -1
View File
@@ -67,7 +67,7 @@ export const WebCommand = effectCmd({
UI.println( UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
`${opts.mdnsDomain}:${server.port}`, `${opts.mdns === true ? "opencode.local" : opts.mdns.domain}:${server.port}`,
) )
} }
+14 -15
View File
@@ -2,16 +2,9 @@ import { NamedError } from "@opencode-ai/core/util/error"
import { errorFormat } from "@/util/error" import { errorFormat } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
interface ErrorLike {
name?: string
_tag?: string
message?: string
data?: Record<string, unknown>
}
type ConfigIssue = { message: string; path: string[] } type ConfigIssue = { message: string; path: string[] }
function isTaggedError(error: unknown, tag: string): boolean { function isTaggedError(error: unknown, tag: string): error is Record<string, unknown> {
return isRecord(error) && error._tag === tag return isRecord(error) && error._tag === tag
} }
@@ -39,22 +32,27 @@ function configIssues(input: Record<string, unknown>): ConfigIssue[] {
: [] : []
} }
export function FormatError(input: unknown) { export function FormatError(input: unknown): string | undefined {
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
const formatted = FormatError(input.cause.body)
if (formatted) return formatted
}
// CliError: domain failure surfaced from an effectCmd handler via fail("...") // CliError: domain failure surfaced from an effectCmd handler via fail("...")
if (isTaggedError(input, "CliError")) { if (isTaggedError(input, "CliError")) {
const data = input as ErrorLike & { exitCode?: number } if (typeof input.exitCode === "number") process.exitCode = input.exitCode
if (data.exitCode != null) process.exitCode = data.exitCode return stringField(input, "message") ?? ""
return data.message ?? ""
} }
// MCPFailed: { name: string } // MCPFailed: { name: string }
if (NamedError.hasName(input, "MCPFailed")) { if (NamedError.hasName(input, "MCPFailed")) {
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined
return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.`
} }
// AccountServiceError, AccountTransportError: TaggedErrorClass // AccountServiceError, AccountTransportError: TaggedErrorClass
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) { if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
return (input as ErrorLike).message ?? "" return stringField(input, "message") ?? ""
} }
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
@@ -64,7 +62,7 @@ export function FormatError(input: unknown) {
? providerModelNotFound.suggestions.filter((x) => typeof x === "string") ? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
: [] : []
return [ return [
`Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`, `Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`, `Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`, `Or check your config (opencode.json) provider/model names`,
@@ -112,6 +110,7 @@ export function FormatError(input: unknown) {
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) { if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return "" return ""
} }
return undefined
} }
export function FormatUnknownError(input: unknown): string { export function FormatUnknownError(input: unknown): string {
+2 -2
View File
@@ -47,7 +47,7 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con
const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsExplicitlySet = process.argv.includes("--mdns")
const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : config?.server?.mdnsDomain
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
const hostname = hostnameExplicitlySet const hostname = hostnameExplicitlySet
? args.hostname ? args.hostname
@@ -58,5 +58,5 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors] const cors = [...configCors, ...argsCors]
return { hostname, port, mdns, mdnsDomain, cors } return { type: "tcp" as const, hostname, port, mdns: mdns ? (mdnsDomain ? { domain: mdnsDomain } : true) : undefined, cors }
} }
@@ -1,5 +1,6 @@
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { ConfigError } from "@/config/error"
import { Cause, Effect } from "effect" import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
@@ -18,6 +19,13 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
if (!defect) return Effect.failCause(cause) if (!defect) return Effect.failCause(cause)
const error = defect.defect const error = defect.defect
if (
error instanceof NamedError &&
(ConfigError.InvalidError.isInstance(error) || ConfigError.JsonError.isInstance(error))
) {
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 }))
}
log.error("failed", { error, cause: Cause.pretty(cause) }) log.error("failed", { error, cause: Cause.pretty(cause) })
return Effect.succeed( return Effect.succeed(
+65 -24
View File
@@ -19,33 +19,54 @@ globalThis.AI_SDK_LOG_WARNINGS = false
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
export type Listener = { export type TcpListener = {
type: "tcp"
hostname: string hostname: string
port: number port: number
url: URL url: URL
stop: (close?: boolean) => Promise<void> stop: (close?: boolean) => Promise<void>
} }
export type SocketListener = {
type: "socket"
socket: string
url: URL
stop: (close?: boolean) => Promise<void>
}
export type Listener = TcpListener | SocketListener
type ServerApp = { type ServerApp = {
fetch(request: Request): Response | Promise<Response> fetch(request: Request): Response | Promise<Response>
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response> request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
} }
type ListenOptions = CorsOptions & { export type TcpListenOptions = CorsOptions & {
type: "tcp"
port: number port: number
hostname: string hostname: string
mdns?: boolean mdns?: true | { domain: string }
mdnsDomain?: string
} }
export type SocketListenOptions = CorsOptions & {
type: "socket"
socket: string
}
export type ListenOptions = TcpListenOptions | SocketListenOptions
type ListenerState = { type ListenerState = {
scope: Scope.Scope scope: Scope.Scope
server: Context.Service.Shape<typeof HttpServer.HttpServer> server: Context.Service.Shape<typeof HttpServer.HttpServer>
http: ListenerServer http: ListenerServer
websockets: WebSocketTracker.Interface websockets: WebSocketTracker.Interface
} }
type EffectListener = Omit<Listener, "stop"> & { type EffectTcpListener = Omit<TcpListener, "stop"> & {
stop: (close?: boolean) => Effect.Effect<void> stop: (close?: boolean) => Effect.Effect<void>
} }
type EffectSocketListener = Omit<SocketListener, "stop"> & {
stop: (close?: boolean) => Effect.Effect<void>
}
type EffectListener = EffectTcpListener | EffectSocketListener
interface ListenerServer { interface ListenerServer {
readonly closeAll: Effect.Effect<void> readonly closeAll: Effect.Effect<void>
@@ -72,26 +93,50 @@ export async function openapi() {
export let url: URL export let url: URL
export function listen(opts: TcpListenOptions): Promise<TcpListener>
export function listen(opts: SocketListenOptions): Promise<SocketListener>
export async function listen(opts: ListenOptions): Promise<Listener> { export async function listen(opts: ListenOptions): Promise<Listener> {
const listener = await Effect.runPromise(listenEffect(opts)) const listener = await Effect.runPromise(listenEffect(opts))
const stop = (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined)
if (listener.type === "socket") {
return {
type: "socket" as const,
socket: listener.socket,
url: listener.url,
stop,
}
}
return { return {
type: "tcp" as const,
hostname: listener.hostname, hostname: listener.hostname,
port: listener.port, port: listener.port,
url: listener.url, url: listener.url,
stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), stop,
} }
} }
const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unknown> = Effect.fn("Server.listen")( const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unknown> = Effect.fn("Server.listen")(
function* (opts: ListenOptions) { function* (opts: ListenOptions) {
const state = yield* startWithPortFallback(opts) const state = yield* startWithPortFallback(opts)
if (opts.type === "socket") {
return {
type: "socket" as const,
socket: opts.socket,
url: new URL(`socket:${encodeURIComponent(opts.socket)}`),
stop: yield* makeStop(state, Effect.void),
}
}
const address = yield* tcpAddress(state) const address = yield* tcpAddress(state)
const listenerUrl = makeURL(opts.hostname, address.port) const listenerUrl = new URL("http://localhost")
listenerUrl.hostname = opts.hostname
listenerUrl.port = String(address.port)
url = listenerUrl url = listenerUrl
const unpublishMdns = yield* setupMdns(opts, address.port, state.scope) const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
return { return {
type: "tcp" as const,
hostname: opts.hostname, hostname: opts.hostname,
port: address.port, port: address.port,
url: listenerUrl, url: listenerUrl,
@@ -100,14 +145,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
}, },
) )
function listenerLayer(opts: ListenOptions, port: number) { function listenerLayer(opts: ListenOptions) {
return HttpRouter.serve(HttpApiApp.createRoutes(opts), { return HttpRouter.serve(HttpApiApp.createRoutes(opts), {
middleware: disposeMiddleware, middleware: disposeMiddleware,
disableLogger: true, disableLogger: true,
disableListenLog: true, disableListenLog: true,
}).pipe( }).pipe(
Layer.provideMerge(WebSocketTracker.layer), Layer.provideMerge(WebSocketTracker.layer),
Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), Layer.provideMerge(serverLayer(opts)),
// Install a fresh `ConfigProvider` per listener so `Config.string(...)` // Install a fresh `ConfigProvider` per listener so `Config.string(...)`
// reads reflect the current `process.env`. Effect's default // reads reflect the current `process.env`. Effect's default
// `ConfigProvider` snapshots `process.env` on first read and caches the // `ConfigProvider` snapshots `process.env` on first read and caches the
@@ -118,15 +163,15 @@ function listenerLayer(opts: ListenOptions, port: number) {
} }
function startWithPortFallback(opts: ListenOptions) { function startWithPortFallback(opts: ListenOptions) {
if (opts.port !== 0) return startListener(opts, opts.port) if (opts.type === "socket" || opts.port !== 0) return startListener(opts)
// Match the legacy listener port-resolution behavior: explicit `0` prefers // Match the legacy listener port-resolution behavior: explicit `0` prefers
// 4096 first, then any free port. // 4096 first, then any free port.
return startListener(opts, 4096).pipe(Effect.catch(() => startListener(opts, 0))) return startListener({ ...opts, port: 4096 }).pipe(Effect.catch(() => startListener(opts)))
} }
function startListener(opts: ListenOptions, port: number) { function startListener(opts: ListenOptions) {
const scope = Scope.makeUnsafe() const scope = Scope.makeUnsafe()
return Layer.buildWithMemoMap(listenerLayer(opts, port), Layer.makeMemoMapUnsafe(), scope).pipe( return Layer.buildWithMemoMap(listenerLayer(opts), Layer.makeMemoMapUnsafe(), scope).pipe(
Effect.provide(HttpApiApp.context), Effect.provide(HttpApiApp.context),
Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)),
Effect.map( Effect.map(
@@ -148,20 +193,13 @@ function tcpAddress(state: ListenerState) {
}) })
} }
function makeURL(hostname: string, port: number) { function setupMdns(opts: TcpListenOptions, port: number, scope: Scope.Scope) {
const result = new URL("http://localhost")
result.hostname = hostname
result.port = String(port)
return result
}
function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) {
return Effect.gen(function* () { return Effect.gen(function* () {
const publish = const publish =
opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
if (publish) { if (publish) {
const unpublish = yield* Effect.cached(Effect.sync(() => MDNS.unpublish())) const unpublish = yield* Effect.cached(Effect.sync(() => MDNS.unpublish()))
yield* Effect.sync(() => MDNS.publish(port, opts.mdnsDomain)) yield* Effect.sync(() => MDNS.publish(port, opts.mdns === true ? undefined : opts.mdns.domain))
yield* Scope.addFinalizer(scope, unpublish) yield* Scope.addFinalizer(scope, unpublish)
return unpublish return unpublish
} }
@@ -188,7 +226,7 @@ function forceClose(state: ListenerState) {
return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true }) return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true })
} }
function serverLayer(opts: { port: number; hostname: string }) { function serverLayer(opts: ListenOptions) {
const server = createServer() const server = createServer()
const serverRef = { closeStarted: false, forceStop: false } const serverRef = { closeStarted: false, forceStop: false }
const close = server.close.bind(server) const close = server.close.bind(server)
@@ -203,7 +241,10 @@ function serverLayer(opts: { port: number; hostname: string }) {
}) as typeof server.close }) as typeof server.close
return Layer.mergeAll( return Layer.mergeAll(
NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }), NodeHttpServer.layer(() => server, {
...(opts.type === "socket" ? { path: opts.socket } : { port: opts.port, host: opts.hostname }),
gracefulShutdownTimeout: "1 second",
}),
Layer.succeed(ListenerServerService)( Layer.succeed(ListenerServerService)(
ListenerServerService.of({ ListenerServerService.of({
closeAll: Effect.sync(() => { closeAll: Effect.sync(() => {
@@ -5,6 +5,7 @@
*/ */
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures" import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures"
import { ConfigError } from "@/config/error"
describe("aggregateFailures", () => { describe("aggregateFailures", () => {
test("returns null when every result is fulfilled", () => { test("returns null when every result is fulfilled", () => {
@@ -41,11 +42,48 @@ describe("aggregateFailures", () => {
expect(err!.message).toContain("agents: boom") expect(err!.message).toContain("agents: boom")
}) })
test("formats structured config errors hidden inside SDK error causes", () => {
const configError = new ConfigError.InvalidError({
path: "/tmp/opencode.json",
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
})
const err = aggregateFailures([
{
name: "config.get",
result: {
status: "rejected",
reason: new Error("ConfigInvalidError", {
cause: {
body: configError.toObject(),
},
}),
},
},
])
expect(err!.message).toContain("config.get: Configuration is invalid at /tmp/opencode.json")
expect(err!.message).toContain("Expected object provider.anthropic.options")
})
test("deduplicates identical failure messages across startup requests", () => {
const reason = new Error("same config problem")
const err = aggregateFailures([
{ name: "config.providers", result: { status: "rejected", reason } },
{ name: "provider.list", result: { status: "rejected", reason } },
{ name: "app.agents", result: { status: "rejected", reason } },
{ name: "config.get", result: { status: "rejected", reason } },
{ name: "project.sync", result: { status: "fulfilled", value: undefined } },
])
expect(err!.message).toContain("4 of 5 requests failed: same config problem")
expect(err!.message).toContain("Affected startup requests: config.providers, provider.list, app.agents, config.get")
expect(err!.message.match(/same config problem/g)?.length).toBe(1)
})
test("attaches structured failure list under .cause", () => { test("attaches structured failure list under .cause", () => {
const reason = new Error("nope") const reason = new Error("nope")
const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }])
const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } expect(err!.cause).toEqual({ failures: [{ name: "providers", reason }] })
expect(cause.failures).toEqual([{ name: "providers", reason }])
}) })
test("falls back to String() for opaque reasons", () => { test("falls back to String() for opaque reasons", () => {
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import { isDuplicateEntry, type PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts })
describe("prompt history dedupe", () => {
test("returns false when there is no previous entry", () => {
expect(isDuplicateEntry(undefined, entry("hello"))).toBe(false)
})
test("dedupes identical consecutive entries", () => {
const a = entry("hello world this is over twenty chars")
const b = entry("hello world this is over twenty chars")
expect(isDuplicateEntry(a, b)).toBe(true)
})
test("does not dedupe when input text differs", () => {
expect(isDuplicateEntry(entry("foo"), entry("bar"))).toBe(false)
})
test("does not dedupe when parts differ", () => {
const a = entry("describe this", [
{
type: "file",
mime: "image/png",
filename: "a.png",
url: "data:image/png;base64,AAA",
},
])
const b = entry("describe this", [
{
type: "file",
mime: "image/png",
filename: "b.png",
url: "data:image/png;base64,BBB",
},
])
expect(isDuplicateEntry(a, b)).toBe(false)
})
test("does not dedupe when mode differs", () => {
expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
})
})
@@ -85,7 +85,9 @@ describe("HttpApi CORS", () => {
it.live("uses custom CORS origins passed to the server", () => it.live("uses custom CORS origins passed to the server", () =>
Effect.gen(function* () { Effect.gen(function* () {
const listener = yield* Effect.acquireRelease( const listener = yield* Effect.acquireRelease(
Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })), Effect.promise(() =>
Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] }),
),
(listener) => Effect.promise(() => listener.stop(true)), (listener) => Effect.promise(() => listener.stop(true)),
) )
@@ -1,6 +1,7 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ConfigError } from "../../src/config/error"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error" import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error"
@@ -50,6 +51,27 @@ describe("HttpApi error middleware", () => {
}), }),
) )
it.live("preserves config defects as client-visible bad requests", () =>
Effect.gen(function* () {
const configError = new ConfigError.InvalidError({
path: "/tmp/opencode.json",
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
})
yield* HttpRouter.add("GET", "/config-error", Effect.die(configError)).pipe(
Layer.provide(errorLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClientRequest.get("/config-error").pipe(HttpClient.execute)
const body = yield* response.json
expect(response.status).toBe(400)
expect(JSON.stringify(body)).toBe(JSON.stringify(configError.toObject()))
}),
)
it.live("does not map storage not-found defects to 404", () => it.live("does not map storage not-found defects to 404", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* HttpRouter.add( yield* HttpRouter.add(
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import http from "node:http"
import net from "node:net" import net from "node:net"
import path from "node:path"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
@@ -35,7 +37,7 @@ async function startListener() {
Flag.OPENCODE_SERVER_USERNAME = auth.username Flag.OPENCODE_SERVER_USERNAME = auth.username
process.env.OPENCODE_SERVER_PASSWORD = auth.password process.env.OPENCODE_SERVER_PASSWORD = auth.password
process.env.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 }) return Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0 })
} }
async function startNoAuthListener() { async function startNoAuthListener() {
@@ -43,7 +45,7 @@ async function startNoAuthListener() {
Flag.OPENCODE_SERVER_USERNAME = auth.username Flag.OPENCODE_SERVER_USERNAME = auth.username
delete process.env.OPENCODE_SERVER_PASSWORD delete process.env.OPENCODE_SERVER_PASSWORD
process.env.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 }) return Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0 })
} }
function authorization() { function authorization() {
@@ -134,7 +136,7 @@ async function expectSocketRejected(url: URL, init?: { headers?: Record<string,
) )
} }
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) { function stop(listener: { stop(close?: boolean): Promise<void> }, label: string) {
return withTimeout(listener.stop(true), 10_000, label) return withTimeout(listener.stop(true), 10_000, label)
} }
@@ -285,6 +287,26 @@ describe("HttpApi Server.listen", () => {
).rejects.toThrow() ).rejects.toThrow()
}) })
test("listens on a socket path", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const socket =
process.platform === "win32"
? `\\\\.\\pipe\\opencode-test-${process.pid}-${Date.now()}`
: path.join(tmp.path, "opencode.sock")
const listener = await Server.listen({ type: "socket", socket })
try {
expect(listener.type).toBe("socket")
expect(listener.socket).toBe(socket)
expect(listener.url.href).toBe(`socket:${encodeURIComponent(socket)}`)
const response = await requestSocketRoot(socket)
expect(response.statusCode).toBe(200)
expect(response.body).toContain("OpenCode")
} finally {
await stop(listener, "timed out cleaning up socket listener")
}
})
test("default in-process handler does not emit Effect HTTP response logs", async () => { test("default in-process handler does not emit Effect HTTP response logs", async () => {
let output = "" let output = ""
// oxlint-disable-next-line typescript-eslint/unbound-method -- restored in finally after temporarily capturing stderr. // oxlint-disable-next-line typescript-eslint/unbound-method -- restored in finally after temporarily capturing stderr.
@@ -433,3 +455,18 @@ function occupyPort(port: number) {
server.listen(port, "127.0.0.1", () => resolve(server)) server.listen(port, "127.0.0.1", () => resolve(server))
}) })
} }
function requestSocketRoot(socket: string) {
return new Promise<{ statusCode?: number; body: string }>((resolve, reject) => {
const request = http.request(
{ socketPath: socket, path: "/", method: "GET" },
(response) => {
const chunks: Buffer[] = []
response.on("data", (chunk: Buffer) => chunks.push(chunk))
response.on("end", () => resolve({ statusCode: response.statusCode, body: Buffer.concat(chunks).toString() }))
},
)
request.on("error", reject)
request.end()
})
}
@@ -45,7 +45,7 @@ describe("HttpApi Server.listen mDNS", () => {
test("skips publish for loopback hostnames", async () => { test("skips publish for loopback hostnames", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode" Flag.OPENCODE_SERVER_USERNAME = "opencode"
const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, mdns: true }) const listener = await Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0, mdns: true })
try { try {
expect(events.filter((e) => e.kind === "publish")).toEqual([]) expect(events.filter((e) => e.kind === "publish")).toEqual([])
} finally { } finally {
@@ -57,7 +57,7 @@ describe("HttpApi Server.listen mDNS", () => {
test("publishes for non-loopback hostnames and unpublishes on stop", async () => { test("publishes for non-loopback hostnames and unpublishes on stop", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode" Flag.OPENCODE_SERVER_USERNAME = "opencode"
const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) const listener = await Server.listen({ type: "tcp", hostname: "0.0.0.0", port: 0, mdns: true })
try { try {
const published = events.filter((e) => e.kind === "publish") const published = events.filter((e) => e.kind === "publish")
expect(published.length).toBe(1) expect(published.length).toBe(1)
@@ -73,7 +73,7 @@ describe("HttpApi Server.listen mDNS", () => {
test("scope finalizer unpublishes even if stop() is not called for force-close", async () => { test("scope finalizer unpublishes even if stop() is not called for force-close", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode" Flag.OPENCODE_SERVER_USERNAME = "opencode"
const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) const listener = await Server.listen({ type: "tcp", hostname: "0.0.0.0", port: 0, mdns: true })
expect(events.filter((e) => e.kind === "publish").length).toBe(1) expect(events.filter((e) => e.kind === "publish").length).toBe(1)
// Plain (graceful) stop without close=true should still unpublish. // Plain (graceful) stop without close=true should still unpublish.
await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener") await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener")
+3 -3
View File
@@ -22,9 +22,9 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.10", "@opentui/core": ">=0.2.11",
"@opentui/keymap": ">=0.2.10", "@opentui/keymap": ">=0.2.11",
"@opentui/solid": ">=0.2.10" "@opentui/solid": ">=0.2.11"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@opentui/core": { "@opentui/core": {