Prepare Effect HttpApi backend parity (#24853)

This commit is contained in:
Kit Langton
2026-04-29 09:34:50 -04:00
committed by GitHub
parent 65ba1f6c13
commit 6015084fa2
98 changed files with 4290 additions and 2766 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Compare SDK types generated from Hono vs HttpApi specs.
# Sorts types alphabetically so only meaningful body differences show.
#
# Usage: ./scripts/diff-sdk-types.sh # full diff
# ./scripts/diff-sdk-types.sh --stat # summary only
set -euo pipefail
DIR="$(cd "$(dirname "$0")/.." && pwd)"
SDK="$(cd "$DIR/../sdk/js" && pwd)"
normalize() {
python3 -c "
import re, sys
content = open(sys.argv[1]).read()
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
header, body = blocks[0], blocks[1:]
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
sys.stdout.write(header + ''.join(body))
" "$1"
}
echo "Generating Hono SDK..." >&2
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "Generating HttpApi SDK..." >&2
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "" >&2
if [[ "${1:-}" == "--stat" ]]; then
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
echo ""
if [[ $honly -gt 0 ]]; then
echo "=== Hono-only types ==="
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
echo ""
fi
if [[ $aonly -gt 0 ]]; then
echo "=== HttpApi-only types ==="
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
fi
else
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
fi
@@ -129,6 +129,14 @@ Required before route deletion:
- Compare generated SDK output against `dev` for every route group deletion. - Compare generated SDK output against `dev` for every route group deletion.
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths. - Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
### 5. Make HttpApi Default For JSON Routes ### 5. Make HttpApi Default For JSON Routes
After JSON parity and SDK generation are covered: After JSON parity and SDK generation are covered:
+3 -3
View File
@@ -31,8 +31,8 @@ export const Info = Schema.Struct({
mode: Schema.Literals(["subagent", "primary", "all"]), mode: Schema.Literals(["subagent", "primary", "all"]),
native: Schema.optional(Schema.Boolean), native: Schema.optional(Schema.Boolean),
hidden: Schema.optional(Schema.Boolean), hidden: Schema.optional(Schema.Boolean),
topP: Schema.optional(Schema.Number), topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String), color: Schema.optional(Schema.String),
permission: Permission.Ruleset, permission: Permission.Ruleset,
model: Schema.optional( model: Schema.optional(
@@ -44,7 +44,7 @@ export const Info = Schema.Struct({
variant: Schema.optional(Schema.String), variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String), prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown), options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Number), steps: Schema.optional(Schema.Finite),
}) })
.annotate({ identifier: "Agent" }) .annotate({ identifier: "Agent" })
.pipe(withStatics((s) => ({ zod: zod(s) }))) .pipe(withStatics((s) => ({ zod: zod(s) })))
+2 -1
View File
@@ -1,6 +1,7 @@
import path from "path" import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -14,7 +15,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"), type: Schema.Literal("oauth"),
refresh: Schema.String, refresh: Schema.String,
access: Schema.String, access: Schema.String,
expires: Schema.Number, expires: NonNegativeInt,
accountId: Schema.optional(Schema.String), accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String), enterpriseUrl: Schema.optional(Schema.String),
}) {} }) {}
+12
View File
@@ -34,4 +34,16 @@ export function payloads() {
.toArray() .toArray()
} }
export function effectPayloads() {
return registry
.entries()
.map(([type, def]) =>
Schema.Struct({
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),
)
.toArray()
}
export * as BusEvent from "./bus-event" export * as BusEvent from "./bus-event"
+2 -1
View File
@@ -1,5 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { PositiveInt } from "@/util/schema"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000 const DEFAULT_TOAST_DURATION = 5000
@@ -38,7 +39,7 @@ export const TuiEvent = {
title: Schema.optional(Schema.String), title: Schema.optional(Schema.String),
message: Schema.String, message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]), variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
description: "Duration in milliseconds", description: "Duration in milliseconds",
}), }),
}), }),
+2 -2
View File
@@ -26,8 +26,8 @@ const AgentSchema = Schema.StructWithRest(
variant: Schema.optional(Schema.String).annotate({ variant: Schema.optional(Schema.String).annotate({
description: "Default model variant for this agent (applies only when using the agent's configured model).", description: "Default model variant for this agent (applies only when using the agent's configured model).",
}), }),
temperature: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Finite),
top_p: Schema.optional(Schema.Number), top_p: Schema.optional(Schema.Finite),
prompt: Schema.optional(Schema.String), prompt: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({ tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead", description: "@deprecated Use 'permission' field instead",
@@ -1,10 +1,11 @@
import { Schema } from "effect" import { Schema } from "effect"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({ export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String), activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: Schema.Number, switchableOrgCount: NonNegativeInt,
}) { }) {
static readonly zod = zod(this) static readonly zod = zod(this)
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { PositiveInt, withStatics } from "@/util/schema"
export const Local = Schema.Struct({ export const Local = Schema.Struct({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
@@ -13,7 +13,7 @@ export const Local = Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({ enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup", description: "Enable or disable the MCP server on startup",
}), }),
timeout: Schema.optional(Schema.Number).annotate({ timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}), }),
}) })
@@ -49,7 +49,7 @@ export const Remote = Schema.Struct({
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({ oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
}), }),
timeout: Schema.optional(Schema.Number).annotate({ timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}), }),
}) })
+11 -11
View File
@@ -21,25 +21,25 @@ export const Model = Schema.Struct({
), ),
cost: Schema.optional( cost: Schema.optional(
Schema.Struct({ Schema.Struct({
input: Schema.Number, input: Schema.Finite,
output: Schema.Number, output: Schema.Finite,
cache_read: Schema.optional(Schema.Number), cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Number), cache_write: Schema.optional(Schema.Finite),
context_over_200k: Schema.optional( context_over_200k: Schema.optional(
Schema.Struct({ Schema.Struct({
input: Schema.Number, input: Schema.Finite,
output: Schema.Number, output: Schema.Finite,
cache_read: Schema.optional(Schema.Number), cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Number), cache_write: Schema.optional(Schema.Finite),
}), }),
), ),
}), }),
), ),
limit: Schema.optional( limit: Schema.optional(
Schema.Struct({ Schema.Struct({
context: Schema.Number, context: Schema.Finite,
input: Schema.optional(Schema.Number), input: Schema.optional(Schema.Finite),
output: Schema.Number, output: Schema.Finite,
}), }),
), ),
modalities: Schema.optional( modalities: Schema.optional(
+7 -7
View File
@@ -15,12 +15,12 @@ import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected" import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { type DeepMutable, withStatics } from "@/util/schema" import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
export const Info = Schema.Struct({ export const Info = Schema.Struct({
path: Schema.String, path: Schema.String,
added: Schema.Int, added: NonNegativeInt,
removed: Schema.Int, removed: NonNegativeInt,
status: Schema.Literals(["added", "deleted", "modified"]), status: Schema.Literals(["added", "deleted", "modified"]),
}) })
.annotate({ identifier: "File" }) .annotate({ identifier: "File" })
@@ -39,10 +39,10 @@ export const Node = Schema.Struct({
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>> export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
const Hunk = Schema.Struct({ const Hunk = Schema.Struct({
oldStart: Schema.Number, oldStart: NonNegativeInt,
oldLines: Schema.Number, oldLines: NonNegativeInt,
newStart: Schema.Number, newStart: NonNegativeInt,
newLines: Schema.Number, newLines: NonNegativeInt,
lines: Schema.Array(Schema.String), lines: Schema.Array(Schema.String),
}) })
+14 -14
View File
@@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
import { which } from "@/util/which" import { which } from "@/util/which"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "ripgrep" }) const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0" const VERSION = "15.1.0"
@@ -27,19 +27,19 @@ const PLATFORM = {
} as const } as const
const TimeStats = Schema.Struct({ const TimeStats = Schema.Struct({
secs: Schema.Number, secs: NonNegativeInt,
nanos: Schema.Number, nanos: NonNegativeInt,
human: Schema.String, human: Schema.String,
}) })
const Stats = Schema.Struct({ const Stats = Schema.Struct({
elapsed: TimeStats, elapsed: TimeStats,
searches: Schema.Number, searches: NonNegativeInt,
searches_with_match: Schema.Number, searches_with_match: NonNegativeInt,
bytes_searched: Schema.Number, bytes_searched: NonNegativeInt,
bytes_printed: Schema.Number, bytes_printed: NonNegativeInt,
matched_lines: Schema.Number, matched_lines: NonNegativeInt,
matches: Schema.Number, matches: NonNegativeInt,
}) })
const PathText = Schema.Struct({ const PathText = Schema.Struct({
@@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({
lines: Schema.Struct({ lines: Schema.Struct({
text: Schema.String, text: Schema.String,
}), }),
line_number: Schema.Number, line_number: NonNegativeInt,
absolute_offset: Schema.Number, absolute_offset: NonNegativeInt,
submatches: Schema.Array( submatches: Schema.Array(
Schema.Struct({ Schema.Struct({
match: Schema.Struct({ match: Schema.Struct({
text: Schema.String, text: Schema.String,
}), }),
start: Schema.Number, start: NonNegativeInt,
end: Schema.Number, end: NonNegativeInt,
}), }),
), ),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -80,7 +80,7 @@ const End = Schema.Struct({
type: Schema.Literal("end"), type: Schema.Literal("end"),
data: Schema.Struct({ data: Schema.Struct({
path: PathText, path: PathText,
binary_offset: Schema.NullOr(Schema.Number), binary_offset: Schema.NullOr(NonNegativeInt),
stats: Stats, stats: Stats,
}), }),
}) })
+5 -5
View File
@@ -13,7 +13,7 @@ import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context, Schema } from "effect" import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod, ZodOverride } from "@/util/effect-zod" import { zod, ZodOverride } from "@/util/effect-zod"
const log = Log.create({ service: "lsp" }) const log = Log.create({ service: "lsp" })
@@ -23,8 +23,8 @@ export const Event = {
} }
const Position = Schema.Struct({ const Position = Schema.Struct({
line: Schema.Number, line: NonNegativeInt,
character: Schema.Number, character: NonNegativeInt,
}) })
export const Range = Schema.Struct({ export const Range = Schema.Struct({
@@ -37,7 +37,7 @@ export type Range = typeof Range.Type
export const Symbol = Schema.Struct({ export const Symbol = Schema.Struct({
name: Schema.String, name: Schema.String,
kind: Schema.Number, kind: NonNegativeInt,
location: Schema.Struct({ location: Schema.Struct({
uri: Schema.String, uri: Schema.String,
range: Range, range: Range,
@@ -50,7 +50,7 @@ export type Symbol = typeof Symbol.Type
export const DocumentSymbol = Schema.Struct({ export const DocumentSymbol = Schema.Struct({
name: Schema.String, name: Schema.String,
detail: Schema.optional(Schema.String), detail: Schema.optional(Schema.String),
kind: Schema.Number, kind: NonNegativeInt,
range: Range, range: Range,
selectionRange: Range, selectionRange: Range,
}) })
+4 -4
View File
@@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "project" }) const log = Log.create({ service: "project" })
@@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({
}) })
const ProjectTime = Schema.Struct({ const ProjectTime = Schema.Struct({
created: Schema.Number, created: NonNegativeInt,
updated: Schema.Number, updated: NonNegativeInt,
initialized: Schema.optional(Schema.Number), initialized: Schema.optional(NonNegativeInt),
}) })
export const Info = Schema.Struct({ export const Info = Schema.Struct({
+3 -3
View File
@@ -9,7 +9,7 @@ import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git" import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "vcs" }) const log = Log.create({ service: "vcs" })
@@ -125,8 +125,8 @@ export type Info = Schema.Schema.Type<typeof Info>
export const FileDiff = Schema.Struct({ export const FileDiff = Schema.Struct({
file: Schema.String, file: Schema.String,
patch: Schema.String, patch: Schema.String,
additions: Schema.Number, additions: NonNegativeInt,
deletions: Schema.Number, deletions: NonNegativeInt,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
}) })
.annotate({ identifier: "VcsFileDiff" }) .annotate({ identifier: "VcsFileDiff" })
+2 -2
View File
@@ -58,13 +58,13 @@ export class Authorization extends Schema.Class<Authorization>("ProviderAuthAuth
} }
export const AuthorizeInput = Schema.Struct({ export const AuthorizeInput = Schema.Struct({
method: Schema.Number.annotate({ description: "Auth method index" }), method: Schema.Finite.annotate({ description: "Auth method index" }),
inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }), inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput> export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput>
export const CallbackInput = Schema.Struct({ export const CallbackInput = Schema.Struct({
method: Schema.Number.annotate({ description: "Auth method index" }), method: Schema.Finite.annotate({ description: "Auth method index" }),
code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput> export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
+11 -11
View File
@@ -22,16 +22,16 @@ const filepath = path.join(
const ttl = 5 * 60 * 1000 const ttl = 5 * 60 * 1000
const Cost = Schema.Struct({ const Cost = Schema.Struct({
input: Schema.Number, input: Schema.Finite,
output: Schema.Number, output: Schema.Finite,
cache_read: Schema.optional(Schema.Number), cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Number), cache_write: Schema.optional(Schema.Finite),
context_over_200k: Schema.optional( context_over_200k: Schema.optional(
Schema.Struct({ Schema.Struct({
input: Schema.Number, input: Schema.Finite,
output: Schema.Number, output: Schema.Finite,
cache_read: Schema.optional(Schema.Number), cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Number), cache_write: Schema.optional(Schema.Finite),
}), }),
), ),
}) })
@@ -55,9 +55,9 @@ export const Model = Schema.Struct({
), ),
cost: Schema.optional(Cost), cost: Schema.optional(Cost),
limit: Schema.Struct({ limit: Schema.Struct({
context: Schema.Number, context: Schema.Finite,
input: Schema.optional(Schema.Number), input: Schema.optional(Schema.Finite),
output: Schema.Number, output: Schema.Finite,
}), }),
modalities: Schema.optional( modalities: Schema.optional(
Schema.Struct({ Schema.Struct({
+9 -9
View File
@@ -848,27 +848,27 @@ const ProviderCapabilities = Schema.Struct({
}) })
const ProviderCacheCost = Schema.Struct({ const ProviderCacheCost = Schema.Struct({
read: Schema.Number, read: Schema.Finite,
write: Schema.Number, write: Schema.Finite,
}) })
const ProviderCost = Schema.Struct({ const ProviderCost = Schema.Struct({
input: Schema.Number, input: Schema.Finite,
output: Schema.Number, output: Schema.Finite,
cache: ProviderCacheCost, cache: ProviderCacheCost,
experimentalOver200K: Schema.optional( experimentalOver200K: Schema.optional(
Schema.Struct({ Schema.Struct({
input: Schema.Number, input: Schema.Finite,
output: Schema.Number, output: Schema.Finite,
cache: ProviderCacheCost, cache: ProviderCacheCost,
}), }),
), ),
}) })
const ProviderLimit = Schema.Struct({ const ProviderLimit = Schema.Struct({
context: Schema.Number, context: Schema.Finite,
input: Schema.optional(Schema.Number), input: Schema.optional(Schema.Finite),
output: Schema.Number, output: Schema.Finite,
}) })
export const Model = Schema.Struct({ export const Model = Schema.Struct({
+5 -5
View File
@@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { PtyID } from "./schema" import { PtyID } from "./schema"
import { Effect, Layer, Context, Schema, Types } from "effect" import { Effect, Layer, Context, Schema, Types } from "effect"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "pty" }) const log = Log.create({ service: "pty" })
@@ -62,7 +62,7 @@ export const Info = Schema.Struct({
args: Schema.Array(Schema.String), args: Schema.Array(Schema.String),
cwd: Schema.String, cwd: Schema.String,
status: Schema.Literals(["running", "exited"]), status: Schema.Literals(["running", "exited"]),
pid: Schema.Number, pid: PositiveInt,
}) })
.annotate({ identifier: "Pty" }) .annotate({ identifier: "Pty" })
.pipe(withStatics((s) => ({ zod: zod(s) }))) .pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -83,8 +83,8 @@ export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String), title: Schema.optional(Schema.String),
size: Schema.optional( size: Schema.optional(
Schema.Struct({ Schema.Struct({
rows: Schema.Number, rows: PositiveInt,
cols: Schema.Number, cols: PositiveInt,
}), }),
), ),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -94,7 +94,7 @@ export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInpu
export const Event = { export const Event = {
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })), Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })), Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: Schema.Number })), Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })),
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })), Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
} }
+32
View File
@@ -0,0 +1,32 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
export type Backend = "effect-httpapi" | "hono"
export type Selection = {
backend: Backend
reason: "env" | "stable" | "explicit"
}
export type Attributes = ReturnType<typeof attributes>
export function select(): Selection {
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" }
return { backend: "hono", reason: "stable" }
}
export function attributes(selection: Selection): Record<string, string> {
return {
"opencode.server.backend": selection.backend,
"opencode.server.backend.reason": selection.reason,
"opencode.installation.channel": InstallationChannel,
"opencode.installation.version": InstallationVersion,
}
}
export function force(selection: Selection, backend: Backend): Selection {
return {
backend,
reason: selection.backend === backend ? selection.reason : "explicit",
}
}
+12 -11
View File
@@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { basicAuth } from "hono/basic-auth" import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors" import { cors } from "hono/cors"
import { compress } from "hono/compress" import { compress } from "hono/compress"
import * as ServerBackend from "./backend"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -49,20 +50,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
return basicAuth({ username, password })(c, next) return basicAuth({ username, password })(c, next)
} }
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => { export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler {
const skip = c.req.path === "/log" return async (c, next) => {
if (!skip) { const skip = c.req.path === "/log"
log.info("request", { if (skip) return next()
const attributes = {
method: c.req.method, method: c.req.method,
path: c.req.path, path: c.req.path,
}) ...backendAttributes,
}
log.info("request", attributes)
const timer = log.time("request", attributes)
await next()
timer.stop()
} }
const timer = log.time("request", {
method: c.req.method,
path: c.req.path,
})
await next()
if (!skip) timer.stop()
} }
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
+4 -4
View File
@@ -33,7 +33,7 @@ function headers(req: Request, extra?: HeadersInit) {
return out return out
} }
function protocols(req: Request) { export function websocketProtocols(req: Request) {
const value = req.headers.get("sec-websocket-protocol") const value = req.headers.get("sec-websocket-protocol")
if (!value) return [] if (!value) return []
return value return value
@@ -42,7 +42,7 @@ function protocols(req: Request) {
.filter(Boolean) .filter(Boolean)
} }
function socket(url: string | URL) { export function websocketTargetURL(url: string | URL) {
const next = new URL(url) const next = new URL(url)
if (next.protocol === "http:") next.protocol = "ws:" if (next.protocol === "http:") next.protocol = "ws:"
if (next.protocol === "https:") next.protocol = "wss:" if (next.protocol === "https:") next.protocol = "wss:"
@@ -69,7 +69,7 @@ const app = (upgrade: UpgradeWebSocket) =>
ws.close(1011, "missing proxy target") ws.close(1011, "missing proxy target")
return return
} }
remote = new WebSocket(url, protocols(c.req.raw)) remote = new WebSocket(url, websocketProtocols(c.req.raw))
remote.binaryType = "arraybuffer" remote.binaryType = "arraybuffer"
remote.onopen = () => { remote.onopen = () => {
for (const item of queue) remote?.send(item) for (const item of queue) remote?.send(item)
@@ -150,7 +150,7 @@ export function websocket(
proxy.pathname = "/__workspace_ws" proxy.pathname = "/__workspace_ws"
proxy.search = "" proxy.search = ""
const next = new Headers(req.headers) const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", socket(target)) next.set("x-opencode-proxy-url", websocketTargetURL(target))
for (const [key, value] of new Headers(extra).entries()) { for (const [key, value] of new Headers(extra).entries()) {
next.set(key, value) next.set(key, value)
} }
@@ -0,0 +1,54 @@
import { Schema } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { EventApi } from "./event"
import { ExperimentalApi } from "./groups/experimental"
import { FileApi } from "./groups/file"
import { GlobalApi } from "./groups/global"
import { InstanceApi } from "./groups/instance"
import { McpApi } from "./groups/mcp"
import { PermissionApi } from "./groups/permission"
import { ProjectApi } from "./groups/project"
import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty"
import { QuestionApi } from "./groups/question"
import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
// SSE event schemas built from the same BusEvent/SyncEvent registries that
// the Hono spec uses, so both specs emit identical Event/SyncEvent components.
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
const SyncEventSchemas = SyncEvent.effectPayloads()
export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi)
export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ConfigApi)
.addHttpApi(ExperimentalApi)
.addHttpApi(FileApi)
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(ProjectApi)
.addHttpApi(PtyApi)
.addHttpApi(QuestionApi)
.addHttpApi(PermissionApi)
.addHttpApi(ProviderApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
export type RootHttpApiType = typeof RootHttpApi
export type InstanceHttpApiType = typeof InstanceHttpApi
@@ -4,6 +4,7 @@ import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -27,8 +28,13 @@ export const EventApi = HttpApi.make("event").add(
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })), .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
) )
function eventData(data: unknown) { function eventData(data: unknown): Sse.Event {
return `data: ${JSON.stringify(data)}\n\n` return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
} }
export const eventRoute = HttpRouter.add( export const eventRoute = HttpRouter.add(
@@ -47,6 +53,7 @@ export const eventRoute = HttpRouter.add(
Stream.make({ type: "server.connected", properties: {} }).pipe( Stream.make({ type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData), Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText, Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
), ),
@@ -1,259 +0,0 @@
import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { Installation } from "@/installation"
import { Instance } from "@/project/instance"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const log = Log.create({ service: "server" })
const GlobalHealth = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
}).annotate({ identifier: "GlobalHealth" })
const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Unknown,
}).annotate({ identifier: "GlobalEvent" })
const GlobalUpgradeInput = Schema.Struct({
target: Schema.optional(Schema.String),
}).annotate({ identifier: "GlobalUpgradeInput" })
const GlobalUpgradeResult = Schema.Union([
Schema.Struct({
success: Schema.Literal(true),
version: Schema.String,
}),
Schema.Struct({
success: Schema.Literal(false),
error: Schema.String,
}),
]).annotate({ identifier: "GlobalUpgradeResult" })
export const GlobalPaths = {
health: "/global/health",
event: "/global/event",
config: "/global/config",
dispose: "/global/dispose",
upgrade: "/global/upgrade",
} as const
export const GlobalApi = HttpApi.make("global").add(
HttpApiGroup.make("global")
.add(
HttpApiEndpoint.get("health", GlobalPaths.health, {
success: GlobalHealth,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.health",
summary: "Get health",
description: "Get health information about the OpenCode server.",
}),
),
HttpApiEndpoint.get("event", GlobalPaths.event, {
success: GlobalEventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.event",
summary: "Get global events",
description: "Subscribe to global events from the OpenCode system using server-sent events.",
}),
),
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.get",
summary: "Get global configuration",
description: "Retrieve the current global OpenCode configuration settings and preferences.",
}),
),
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
payload: Config.Info,
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.update",
summary: "Update global configuration",
description: "Update global OpenCode configuration settings and preferences.",
}),
),
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.dispose",
summary: "Dispose instance",
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
}),
),
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
payload: GlobalUpgradeInput,
success: GlobalUpgradeResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.upgrade",
summary: "Upgrade opencode",
description: "Upgrade opencode to the specified version or latest if not specified.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
)
function eventData(data: unknown) {
return `data: ${JSON.stringify(data)}\n\n`
}
function parseBody(body: string) {
try {
return JSON.parse(body || "{}") as unknown
} catch {
return undefined
}
}
function eventResponse() {
const encoder = new TextEncoder()
let heartbeat: ReturnType<typeof setInterval> | undefined
let unsubscribe = () => {}
let done = false
const cleanup = () => {
if (done) return
done = true
if (heartbeat) clearInterval(heartbeat)
unsubscribe()
log.info("global event disconnected")
}
log.info("global event connected")
return HttpServerResponse.raw(
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
const write = (data: unknown) => {
if (done) return
try {
controller.enqueue(encoder.encode(eventData(data)))
} catch {
cleanup()
}
}
const handler = (event: GlobalBusEvent) => write(event)
unsubscribe = () => GlobalBus.off("event", handler)
GlobalBus.on("event", handler)
write({ payload: { type: "server.connected", properties: {} } })
heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000)
},
cancel: cleanup,
}),
{
headers: {
"Cache-Control": "no-cache, no-transform",
"Content-Type": "text/event-stream",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
),
)
}
export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) =>
Effect.gen(function* () {
const config = yield* Config.Service
const installation = yield* Installation.Service
const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion }
})
const event = Effect.fn("GlobalHttpApi.event")(function* () {
return eventResponse()
})
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
return yield* config.getGlobal()
})
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
return yield* config.updateGlobal(ctx.payload)
})
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
yield* Effect.promise(() => Instance.disposeAll())
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.disposed", properties: {} },
})
return true
})
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
const method = yield* installation.method()
if (method === "unknown") {
return {
status: 400,
body: { success: false as const, error: "Unknown installation method" },
}
}
const target = ctx.payload.target || (yield* installation.latest(method))
const result = yield* installation.upgrade(method, target).pipe(
Effect.as({ status: 200, body: { success: true as const, version: target } }),
Effect.catch((err) =>
Effect.succeed({
status: 500,
body: {
success: false as const,
error: err instanceof Error ? err.message : String(err),
},
}),
),
)
if (!result.body.success) return result
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return result
})
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const json = parseBody(body)
if (json === undefined) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
Effect.map((payload) => ({ valid: true as const, payload })),
Effect.catch(() => Effect.succeed({ valid: false as const })),
)
if (!payload.valid) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const result = yield* upgrade({ payload: payload.payload })
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
})
return handlers
.handle("health", health)
.handleRaw("event", event)
.handle("configGet", configGet)
.handle("configUpdate", configUpdate)
.handle("dispose", dispose)
.handleRaw("upgrade", upgradeRaw)
}),
)
@@ -1,10 +1,9 @@
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Effect } from "effect" import { Authorization } from "../auth"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { InstanceContextMiddleware } from "../instance-context"
import { Authorization } from "./auth" import { described } from "./metadata"
import { markInstanceForDisposal } from "./lifecycle"
const root = "/config" const root = "/config"
@@ -13,7 +12,7 @@ export const ConfigApi = HttpApi.make("config")
HttpApiGroup.make("config") HttpApiGroup.make("config")
.add( .add(
HttpApiEndpoint.get("get", root, { HttpApiEndpoint.get("get", root, {
success: Config.Info, success: described(Config.Info, "Get config info"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "config.get", identifier: "config.get",
@@ -23,7 +22,8 @@ export const ConfigApi = HttpApi.make("config")
), ),
HttpApiEndpoint.patch("update", root, { HttpApiEndpoint.patch("update", root, {
payload: Config.Info, payload: Config.Info,
success: Config.Info, success: described(Config.Info, "Successfully updated config"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "config.update", identifier: "config.update",
@@ -32,7 +32,7 @@ export const ConfigApi = HttpApi.make("config")
}), }),
), ),
HttpApiEndpoint.get("providers", `${root}/providers`, { HttpApiEndpoint.get("providers", `${root}/providers`, {
success: Provider.ConfigProvidersResult, success: described(Provider.ConfigProvidersResult, "List of providers"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "config.providers", identifier: "config.providers",
@@ -47,6 +47,7 @@ export const ConfigApi = HttpApi.make("config")
description: "Experimental HttpApi config routes.", description: "Experimental HttpApi config routes.",
}), }),
) )
.middleware(InstanceContextMiddleware)
.middleware(Authorization), .middleware(Authorization),
) )
.annotateMerge( .annotateMerge(
@@ -56,30 +57,3 @@ export const ConfigApi = HttpApi.make("config")
description: "Experimental HttpApi surface for selected instance routes.", description: "Experimental HttpApi surface for selected instance routes.",
}), }),
) )
export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
const get = Effect.fn("ConfigHttpApi.get")(function* () {
return yield* configSvc.get()
})
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
yield* configSvc.update(ctx.payload, { dispose: false })
yield* markInstanceForDisposal(yield* InstanceState.context)
return ctx.payload
})
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const providers = yield* providerSvc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
}),
)
@@ -1,8 +1,8 @@
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema" import { ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log" import { Schema } from "effect"
import { Effect, Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata"
const AuthParams = Schema.Struct({ const AuthParams = Schema.Struct({
providerID: ProviderID, providerID: ProviderID,
@@ -13,7 +13,7 @@ const LogQuery = Schema.Struct({
workspace: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String),
}) })
const LogInput = Schema.Struct({ export const LogInput = Schema.Struct({
service: Schema.String.annotate({ description: "Service name for the log entry" }), service: Schema.String.annotate({ description: "Service name for the log entry" }),
level: Schema.Union([ level: Schema.Union([
Schema.Literal("debug"), Schema.Literal("debug"),
@@ -25,7 +25,7 @@ const LogInput = Schema.Struct({
extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({
description: "Additional metadata for the log entry", description: "Additional metadata for the log entry",
}), }),
}).annotate({ identifier: "AppLogInput" }) })
export const ControlPaths = { export const ControlPaths = {
auth: "/auth/:providerID", auth: "/auth/:providerID",
@@ -38,7 +38,8 @@ export const ControlApi = HttpApi.make("control").add(
HttpApiEndpoint.put("authSet", ControlPaths.auth, { HttpApiEndpoint.put("authSet", ControlPaths.auth, {
params: AuthParams, params: AuthParams,
payload: Auth.Info, payload: Auth.Info,
success: Schema.Boolean, success: described(Schema.Boolean, "Successfully set authentication credentials"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "auth.set", identifier: "auth.set",
@@ -48,7 +49,8 @@ export const ControlApi = HttpApi.make("control").add(
), ),
HttpApiEndpoint.delete("authRemove", ControlPaths.auth, { HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
params: AuthParams, params: AuthParams,
success: Schema.Boolean, success: described(Schema.Boolean, "Successfully removed authentication credentials"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "auth.remove", identifier: "auth.remove",
@@ -59,7 +61,8 @@ export const ControlApi = HttpApi.make("control").add(
HttpApiEndpoint.post("log", ControlPaths.log, { HttpApiEndpoint.post("log", ControlPaths.log, {
query: LogQuery, query: LogQuery,
payload: LogInput, payload: LogInput,
success: Schema.Boolean, success: described(Schema.Boolean, "Log entry written successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "app.log", identifier: "app.log",
@@ -70,30 +73,3 @@ export const ControlApi = HttpApi.make("control").add(
) )
.annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
) )
export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
const logger = Log.create({ service: ctx.payload.service })
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
return true
})
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
}),
)
@@ -1,24 +1,19 @@
import { Account } from "@/account/account"
import { AccountID, OrgID } from "@/account/schema" import { AccountID, OrgID } from "@/account/schema"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { ProviderID, ModelID } from "@/provider/schema" import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { Effect, Option, Schema, SchemaGetter } from "effect" import { NonNegativeInt } from "@/util/schema"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth" import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const ConsoleStateResponse = Schema.Struct({ const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optionalKey(Schema.String), activeOrgName: Schema.optionalKey(Schema.String),
switchableOrgCount: Schema.Number, switchableOrgCount: NonNegativeInt,
}).annotate({ identifier: "ConsoleState" }) }).annotate({ identifier: "ConsoleState" })
const ConsoleOrgOption = Schema.Struct({ const ConsoleOrgOption = Schema.Struct({
@@ -28,25 +23,25 @@ const ConsoleOrgOption = Schema.Struct({
orgID: Schema.String, orgID: Schema.String,
orgName: Schema.String, orgName: Schema.String,
active: Schema.Boolean, active: Schema.Boolean,
}).annotate({ identifier: "ConsoleOrgOption" }) })
const ConsoleOrgList = Schema.Struct({ const ConsoleOrgList = Schema.Struct({
orgs: Schema.Array(ConsoleOrgOption), orgs: Schema.Array(ConsoleOrgOption),
}).annotate({ identifier: "ConsoleOrgList" }) })
const ConsoleSwitchPayload = Schema.Struct({ export const ConsoleSwitchPayload = Schema.Struct({
accountID: AccountID, accountID: AccountID,
orgID: OrgID, orgID: OrgID,
}).annotate({ identifier: "ConsoleSwitchInput" }) })
const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" }) const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
const ToolListItem = Schema.Struct({ const ToolListItem = Schema.Struct({
id: Schema.String, id: Schema.String,
description: Schema.String, description: Schema.String,
parameters: Schema.Record(Schema.String, Schema.Any), parameters: Schema.Unknown,
}).annotate({ identifier: "ToolListItem" }) }).annotate({ identifier: "ToolListItem" })
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
const ToolListQuery = Schema.Struct({ export const ToolListQuery = Schema.Struct({
provider: ProviderID, provider: ProviderID,
model: ModelID, model: ModelID,
}) })
@@ -57,8 +52,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
encode: SchemaGetter.transform((value) => (value ? "true" : "false")), encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}), }),
) )
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" }) const WorktreeList = Schema.Array(Schema.String)
const SessionListQuery = Schema.Struct({ export const SessionListQuery = Schema.Struct({
directory: Schema.optional(Schema.String), directory: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean), roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString), start: Schema.optional(Schema.NumberFromString),
@@ -85,7 +80,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiGroup.make("experimental") HttpApiGroup.make("experimental")
.add( .add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, { HttpApiEndpoint.get("console", ExperimentalPaths.console, {
success: ConsoleStateResponse, success: described(ConsoleStateResponse, "Active Console provider metadata"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "experimental.console.get", identifier: "experimental.console.get",
@@ -94,7 +89,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
success: ConsoleOrgList, success: described(ConsoleOrgList, "Switchable Console orgs"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "experimental.console.listOrgs", identifier: "experimental.console.listOrgs",
@@ -104,7 +99,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
), ),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
payload: ConsoleSwitchPayload, payload: ConsoleSwitchPayload,
success: Schema.Boolean, success: described(Schema.Boolean, "Switch success"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -115,7 +110,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
), ),
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, { HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
query: ToolListQuery, query: ToolListQuery,
success: ToolList, success: described(ToolList, "Tools"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "tool.list", identifier: "tool.list",
@@ -125,7 +121,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
success: ToolIDs, success: described(ToolIDs, "Tool IDs"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "tool.ids", identifier: "tool.ids",
@@ -135,7 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
success: WorktreeList, success: described(WorktreeList, "List of worktree directories"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "worktree.list", identifier: "worktree.list",
@@ -145,7 +142,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
), ),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
payload: Schema.optional(Worktree.CreateInput), payload: Schema.optional(Worktree.CreateInput),
success: Worktree.Info, success: described(Worktree.Info, "Worktree created"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "worktree.create", identifier: "worktree.create",
@@ -155,7 +153,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
), ),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
payload: Worktree.RemoveInput, payload: Worktree.RemoveInput,
success: Schema.Boolean, success: described(Schema.Boolean, "Worktree removed"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "worktree.remove", identifier: "worktree.remove",
@@ -165,7 +164,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
), ),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
payload: Worktree.ResetInput, payload: Worktree.ResetInput,
success: Schema.Boolean, success: described(Schema.Boolean, "Worktree reset"),
error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "worktree.reset", identifier: "worktree.reset",
@@ -175,7 +175,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
), ),
HttpApiEndpoint.get("session", ExperimentalPaths.session, { HttpApiEndpoint.get("session", ExperimentalPaths.session, {
query: SessionListQuery, query: SessionListQuery,
success: Schema.Array(Session.GlobalInfo), success: described(Schema.Array(Session.GlobalInfo), "List of sessions"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "experimental.session.list", identifier: "experimental.session.list",
@@ -185,7 +185,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}), }),
), ),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
success: Schema.Record(Schema.String, MCP.Resource), success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "experimental.resource.list", identifier: "experimental.resource.list",
@@ -200,6 +200,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "Experimental HttpApi read-only routes.", description: "Experimental HttpApi read-only routes.",
}), }),
) )
.middleware(InstanceContextMiddleware)
.middleware(Authorization), .middleware(Authorization),
) )
.annotateMerge( .annotateMerge(
@@ -209,143 +210,3 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "Experimental HttpApi surface for selected instance routes.", description: "Experimental HttpApi surface for selected instance routes.",
}), }),
) )
export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
Effect.gen(function* () {
const account = yield* Account.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
return {
consoleManagedProviders: state.consoleManagedProviders,
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
})
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
const [groups, active] = yield* Effect.all(
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
const info = Option.getOrUndefined(active)
return {
orgs: groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
),
}
})
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
payload: typeof ConsoleSwitchPayload.Type
}) {
yield* account
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
const list = yield* registry.tools({
providerID: ctx.query.provider,
modelID: ctx.query.model,
agent: yield* agents.get(yield* agents.defaultAgent()),
})
return list.map((item) => ({
id: item.id,
description: item.description,
parameters: EffectZod.toJsonSchema(item.parameters),
}))
})
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
return yield* registry.ids()
})
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
const ctx = yield* InstanceState.context
return yield* project.sandboxes(ctx.project.id)
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
}) {
return yield* worktreeSvc.create(ctx.payload)
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
payload: Worktree.ResetInput
}) {
yield* worktreeSvc.reset(ctx.payload)
return true
})
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
return HttpServerResponse.jsonUnsafe(list, {
headers:
sessions.length > limit && list.length > 0
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
: undefined,
})
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
return handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource)
}),
)
@@ -1,20 +1,21 @@
import { File } from "@/file" import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep" import { Ripgrep } from "@/file/ripgrep"
import * as InstanceState from "@/effect/instance-state"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { Effect, Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth" import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const FileQuery = Schema.Struct({ export const FileQuery = Schema.Struct({
path: Schema.String, path: Schema.String,
}) })
const FindTextQuery = Schema.Struct({ export const FindTextQuery = Schema.Struct({
pattern: Schema.String, pattern: Schema.String,
}) })
const FindFileQuery = Schema.Struct({ export const FindFileQuery = Schema.Struct({
query: Schema.String, query: Schema.String,
dirs: Schema.optional(Schema.Literals(["true", "false"])), dirs: Schema.optional(Schema.Literals(["true", "false"])),
type: Schema.optional(Schema.Literals(["file", "directory"])), type: Schema.optional(Schema.Literals(["file", "directory"])),
@@ -23,7 +24,7 @@ const FindFileQuery = Schema.Struct({
), ),
}) })
const FindSymbolQuery = Schema.Struct({ export const FindSymbolQuery = Schema.Struct({
query: Schema.String, query: Schema.String,
}) })
@@ -42,7 +43,7 @@ export const FileApi = HttpApi.make("file")
.add( .add(
HttpApiEndpoint.get("findText", FilePaths.findText, { HttpApiEndpoint.get("findText", FilePaths.findText, {
query: FindTextQuery, query: FindTextQuery,
success: Schema.Array(Ripgrep.SearchMatch), success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "find.text", identifier: "find.text",
@@ -52,7 +53,7 @@ export const FileApi = HttpApi.make("file")
), ),
HttpApiEndpoint.get("findFile", FilePaths.findFile, { HttpApiEndpoint.get("findFile", FilePaths.findFile, {
query: FindFileQuery, query: FindFileQuery,
success: Schema.Array(Schema.String), success: described(Schema.Array(Schema.String), "File paths"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "find.files", identifier: "find.files",
@@ -62,7 +63,7 @@ export const FileApi = HttpApi.make("file")
), ),
HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, { HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
query: FindSymbolQuery, query: FindSymbolQuery,
success: Schema.Array(LSP.Symbol), success: described(Schema.Array(LSP.Symbol), "Symbols"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "find.symbols", identifier: "find.symbols",
@@ -72,7 +73,7 @@ export const FileApi = HttpApi.make("file")
), ),
HttpApiEndpoint.get("list", FilePaths.list, { HttpApiEndpoint.get("list", FilePaths.list, {
query: FileQuery, query: FileQuery,
success: Schema.Array(File.Node), success: described(Schema.Array(File.Node), "Files and directories"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "file.list", identifier: "file.list",
@@ -82,7 +83,7 @@ export const FileApi = HttpApi.make("file")
), ),
HttpApiEndpoint.get("content", FilePaths.content, { HttpApiEndpoint.get("content", FilePaths.content, {
query: FileQuery, query: FileQuery,
success: File.Content, success: described(File.Content, "File content"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "file.read", identifier: "file.read",
@@ -91,7 +92,7 @@ export const FileApi = HttpApi.make("file")
}), }),
), ),
HttpApiEndpoint.get("status", FilePaths.status, { HttpApiEndpoint.get("status", FilePaths.status, {
success: Schema.Array(File.Info), success: described(Schema.Array(File.Info), "File status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "file.status", identifier: "file.status",
@@ -106,6 +107,7 @@ export const FileApi = HttpApi.make("file")
description: "Experimental HttpApi file routes.", description: "Experimental HttpApi file routes.",
}), }),
) )
.middleware(InstanceContextMiddleware)
.middleware(Authorization), .middleware(Authorization),
) )
.annotateMerge( .annotateMerge(
@@ -115,51 +117,3 @@ export const FileApi = HttpApi.make("file")
description: "Experimental HttpApi surface for selected instance routes.", description: "Experimental HttpApi surface for selected instance routes.",
}), }),
) )
export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
return (yield* ripgrep
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
.pipe(Effect.orDie)).items
})
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) {
return yield* svc.search({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
})
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
return []
})
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
return yield* svc.list(ctx.query.path)
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return yield* svc.read(ctx.query.path)
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return yield* svc.status()
})
return handlers
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status)
}),
)
@@ -0,0 +1,106 @@
import { Config } from "@/config/config"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
const GlobalHealth = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
})
const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]),
}).annotate({ identifier: "GlobalEvent" })
export const GlobalUpgradeInput = Schema.Struct({
target: Schema.optional(Schema.String),
})
const GlobalUpgradeResult = Schema.Union([
Schema.Struct({
success: Schema.Literal(true),
version: Schema.String,
}),
Schema.Struct({
success: Schema.Literal(false),
error: Schema.String,
}),
])
export const GlobalPaths = {
health: "/global/health",
event: "/global/event",
config: "/global/config",
dispose: "/global/dispose",
upgrade: "/global/upgrade",
} as const
export const GlobalApi = HttpApi.make("global").add(
HttpApiGroup.make("global")
.add(
HttpApiEndpoint.get("health", GlobalPaths.health, {
success: described(GlobalHealth, "Health information"),
}).annotateMerge(
OpenApi.annotations({
identifier: "global.health",
summary: "Get health",
description: "Get health information about the OpenCode server.",
}),
),
HttpApiEndpoint.get("event", GlobalPaths.event, {
success: GlobalEventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.event",
summary: "Get global events",
description: "Subscribe to global events from the OpenCode system using server-sent events.",
}),
),
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
success: described(Config.Info, "Get global config info"),
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.get",
summary: "Get global configuration",
description: "Retrieve the current global OpenCode configuration settings and preferences.",
}),
),
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
payload: Config.Info,
success: described(Config.Info, "Successfully updated global config"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.update",
summary: "Update global configuration",
description: "Update global OpenCode configuration settings and preferences.",
}),
),
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
success: described(Schema.Boolean, "Global disposed"),
}).annotateMerge(
OpenApi.annotations({
identifier: "global.dispose",
summary: "Dispose instance",
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
}),
),
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
payload: GlobalUpgradeInput,
success: described(GlobalUpgradeResult, "Upgrade result"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.upgrade",
summary: "Upgrade opencode",
description: "Upgrade opencode to the specified version or latest if not specified.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
)
@@ -1,15 +1,14 @@
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Command } from "@/command" import { Command } from "@/command"
import { Format } from "@/format" import { Format } from "@/format"
import { Global } from "@opencode-ai/core/global"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill" import { Skill } from "@/skill"
import * as InstanceState from "@/effect/instance-state" import { Schema } from "effect"
import { Effect, Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../auth"
import { Authorization } from "./auth" import { InstanceContextMiddleware } from "../instance-context"
import { markInstanceForDisposal } from "./lifecycle" import { described } from "./metadata"
const PathInfo = Schema.Struct({ const PathInfo = Schema.Struct({
home: Schema.String, home: Schema.String,
@@ -19,7 +18,7 @@ const PathInfo = Schema.Struct({
directory: Schema.String, directory: Schema.String,
}).annotate({ identifier: "Path" }) }).annotate({ identifier: "Path" })
const VcsDiffQuery = Schema.Struct({ export const VcsDiffQuery = Schema.Struct({
mode: Vcs.Mode, mode: Vcs.Mode,
}) })
@@ -40,7 +39,7 @@ export const InstanceApi = HttpApi.make("instance")
HttpApiGroup.make("instance") HttpApiGroup.make("instance")
.add( .add(
HttpApiEndpoint.post("dispose", InstancePaths.dispose, { HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
success: Schema.Boolean, success: described(Schema.Boolean, "Instance disposed"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "instance.dispose", identifier: "instance.dispose",
@@ -59,7 +58,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("vcs", InstancePaths.vcs, { HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
success: Vcs.Info, success: described(Vcs.Info, "VCS info"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "vcs.get", identifier: "vcs.get",
@@ -70,7 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
), ),
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, { HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
query: VcsDiffQuery, query: VcsDiffQuery,
success: Schema.Array(Vcs.FileDiff), success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "vcs.diff", identifier: "vcs.diff",
@@ -79,7 +78,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("command", InstancePaths.command, { HttpApiEndpoint.get("command", InstancePaths.command, {
success: Schema.Array(Command.Info), success: described(Schema.Array(Command.Info), "List of commands"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "command.list", identifier: "command.list",
@@ -88,7 +87,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("agent", InstancePaths.agent, { HttpApiEndpoint.get("agent", InstancePaths.agent, {
success: Schema.Array(Agent.Info), success: described(Schema.Array(Agent.Info), "List of agents"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "app.agents", identifier: "app.agents",
@@ -97,7 +96,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("skill", InstancePaths.skill, { HttpApiEndpoint.get("skill", InstancePaths.skill, {
success: Schema.Array(Skill.Info), success: described(Schema.Array(Skill.Info), "List of skills"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "app.skills", identifier: "app.skills",
@@ -106,7 +105,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("lsp", InstancePaths.lsp, { HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
success: Schema.Array(LSP.Status), success: described(Schema.Array(LSP.Status), "LSP server status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "lsp.status", identifier: "lsp.status",
@@ -115,7 +114,7 @@ export const InstanceApi = HttpApi.make("instance")
}), }),
), ),
HttpApiEndpoint.get("formatter", InstancePaths.formatter, { HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
success: Schema.Array(Format.Status), success: described(Schema.Array(Format.Status), "Formatter status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "formatter.status", identifier: "formatter.status",
@@ -130,6 +129,7 @@ export const InstanceApi = HttpApi.make("instance")
description: "Experimental HttpApi instance read routes.", description: "Experimental HttpApi instance read routes.",
}), }),
) )
.middleware(InstanceContextMiddleware)
.middleware(Authorization), .middleware(Authorization),
) )
.annotateMerge( .annotateMerge(
@@ -139,70 +139,3 @@ export const InstanceApi = HttpApi.make("instance")
description: "Experimental HttpApi surface for selected instance routes.", description: "Experimental HttpApi surface for selected instance routes.",
}), }),
) )
export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const command = yield* Command.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const skill = yield* Skill.Service
const vcs = yield* Vcs.Service
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
yield* markInstanceForDisposal(yield* InstanceState.context)
return true
})
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
const ctx = yield* InstanceState.context
return {
home: Global.Path.home,
state: Global.Path.state,
config: Global.Path.config,
worktree: ctx.worktree,
directory: ctx.directory,
}
})
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
return { branch, default_branch }
})
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
return yield* agent.list()
})
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
return yield* skill.all()
})
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
return yield* lsp.status()
})
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
return yield* format.status()
})
return handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter)
}),
)
@@ -1,26 +1,27 @@
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { ConfigMCP } from "@/config/mcp" import { ConfigMCP } from "@/config/mcp"
import { Effect, Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth" import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const AddPayload = Schema.Struct({ export const AddPayload = Schema.Struct({
name: Schema.String, name: Schema.String,
config: ConfigMCP.Info, config: ConfigMCP.Info,
}).annotate({ identifier: "McpAddInput" }) })
const StatusMap = Schema.Record(Schema.String, MCP.Status) export const StatusMap = Schema.Record(Schema.String, MCP.Status)
const AuthStartResponse = Schema.Struct({ export const AuthStartResponse = Schema.Struct({
authorizationUrl: Schema.String, authorizationUrl: Schema.String,
oauthState: Schema.String, })
}).annotate({ identifier: "McpAuthStartResponse" }) export const AuthCallbackPayload = Schema.Struct({
const AuthCallbackPayload = Schema.Struct({
code: Schema.String, code: Schema.String,
}).annotate({ identifier: "McpAuthCallbackInput" }) })
const AuthRemoveResponse = Schema.Struct({ export const AuthRemoveResponse = Schema.Struct({
success: Schema.Literal(true), success: Schema.Literal(true),
}).annotate({ identifier: "McpAuthRemoveResponse" }) })
class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")( export class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
{ error: Schema.String }, { error: Schema.String },
{ httpApiStatus: 400 }, { httpApiStatus: 400 },
) {} ) {}
@@ -39,7 +40,7 @@ export const McpApi = HttpApi.make("mcp")
HttpApiGroup.make("mcp") HttpApiGroup.make("mcp")
.add( .add(
HttpApiEndpoint.get("status", McpPaths.status, { HttpApiEndpoint.get("status", McpPaths.status, {
success: Schema.Record(Schema.String, MCP.Status), success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "mcp.status", identifier: "mcp.status",
@@ -49,7 +50,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("add", McpPaths.status, { HttpApiEndpoint.post("add", McpPaths.status, {
payload: AddPayload, payload: AddPayload,
success: StatusMap, success: described(StatusMap, "MCP server added successfully"),
error: HttpApiError.BadRequest, error: HttpApiError.BadRequest,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@@ -60,8 +61,8 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("authStart", McpPaths.auth, { HttpApiEndpoint.post("authStart", McpPaths.auth, {
params: { name: Schema.String }, params: { name: Schema.String },
success: AuthStartResponse, success: described(AuthStartResponse, "OAuth flow started"),
error: UnsupportedOAuthError, error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "mcp.auth.start", identifier: "mcp.auth.start",
@@ -72,7 +73,8 @@ export const McpApi = HttpApi.make("mcp")
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
params: { name: Schema.String }, params: { name: Schema.String },
payload: AuthCallbackPayload, payload: AuthCallbackPayload,
success: MCP.Status, success: described(MCP.Status, "OAuth authentication completed"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "mcp.auth.callback", identifier: "mcp.auth.callback",
@@ -83,8 +85,8 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
params: { name: Schema.String }, params: { name: Schema.String },
success: MCP.Status, success: described(MCP.Status, "OAuth authentication completed"),
error: UnsupportedOAuthError, error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "mcp.auth.authenticate", identifier: "mcp.auth.authenticate",
@@ -94,7 +96,8 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.delete("authRemove", McpPaths.auth, { HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
params: { name: Schema.String }, params: { name: Schema.String },
success: AuthRemoveResponse, success: described(AuthRemoveResponse, "OAuth credentials removed"),
error: HttpApiError.NotFound,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "mcp.auth.remove", identifier: "mcp.auth.remove",
@@ -104,7 +107,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("connect", McpPaths.connect, { HttpApiEndpoint.post("connect", McpPaths.connect, {
params: { name: Schema.String }, params: { name: Schema.String },
success: Schema.Boolean, success: described(Schema.Boolean, "MCP server connected successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "mcp.connect", identifier: "mcp.connect",
@@ -113,7 +116,7 @@ export const McpApi = HttpApi.make("mcp")
), ),
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
params: { name: Schema.String }, params: { name: Schema.String },
success: Schema.Boolean, success: described(Schema.Boolean, "MCP server disconnected successfully"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "mcp.disconnect", identifier: "mcp.disconnect",
@@ -127,6 +130,7 @@ export const McpApi = HttpApi.make("mcp")
description: "Experimental HttpApi MCP routes.", description: "Experimental HttpApi MCP routes.",
}), }),
) )
.middleware(InstanceContextMiddleware)
.middleware(Authorization), .middleware(Authorization),
) )
.annotateMerge( .annotateMerge(
@@ -136,66 +140,3 @@ export const McpApi = HttpApi.make("mcp")
description: "Experimental HttpApi surface for selected instance routes.", description: "Experimental HttpApi surface for selected instance routes.",
}), }),
) )
export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
const status = Effect.fn("McpHttpApi.status")(function* () {
return yield* mcp.status()
})
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
return yield* Schema.decodeUnknownEffect(StatusMap)(
"status" in result ? { [ctx.payload.name]: result } : result,
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.startAuth(ctx.params.name)
})
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
params: { name: string }
payload: typeof AuthCallbackPayload.Type
}) {
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
})
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.authenticate(ctx.params.name)
})
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
yield* mcp.removeAuth(ctx.params.name)
return { success: true as const }
})
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
yield* mcp.connect(ctx.params.name)
return true
})
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
yield* mcp.disconnect(ctx.params.name)
return true
})
return handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
.handle("authCallback", authCallback)
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect)
}),
)
@@ -0,0 +1,18 @@
import { Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
export function described<S extends Schema.Top>(schema: S, description: string): S {
return schema.annotate({ description }) as S
}
export function responseDescription(description: string) {
return OpenApi.annotations({
transform: (operation) => {
const response = operation.responses?.["200"]
if (response && typeof response === "object" && "description" in response) {
response.description = description
}
return operation
},
})
}
@@ -1,17 +1,23 @@
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { Effect, Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth" import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/permission" const root = "/permission"
const ReplyPayload = Schema.Struct({
reply: Permission.Reply,
message: Schema.optional(Schema.String),
})
export const PermissionApi = HttpApi.make("permission") export const PermissionApi = HttpApi.make("permission")
.add( .add(
HttpApiGroup.make("permission") HttpApiGroup.make("permission")
.add( .add(
HttpApiEndpoint.get("list", root, { HttpApiEndpoint.get("list", root, {
success: Schema.Array(Permission.Request), success: described(Schema.Array(Permission.Request), "List of pending permissions"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "permission.list", identifier: "permission.list",
@@ -21,8 +27,9 @@ export const PermissionApi = HttpApi.make("permission")
), ),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID }, params: { requestID: PermissionID },
payload: Permission.ReplyBody, payload: ReplyPayload,
success: Schema.Boolean, success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "permission.reply", identifier: "permission.reply",
@@ -37,6 +44,7 @@ export const PermissionApi = HttpApi.make("permission")
description: "Experimental HttpApi permission routes.", description: "Experimental HttpApi permission routes.",
}), }),
) )
.middleware(InstanceContextMiddleware)
.middleware(Authorization), .middleware(Authorization),
) )
.annotateMerge( .annotateMerge(
@@ -46,27 +54,3 @@ export const PermissionApi = HttpApi.make("permission")
description: "Experimental HttpApi surface for selected instance routes.", description: "Experimental HttpApi surface for selected instance routes.",
}), }),
) )
export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
Effect.gen(function* () {
const svc = yield* Permission.Service
const list = Effect.fn("PermissionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
})
return true
})
return handlers.handle("list", list).handle("reply", reply)
}),
)
@@ -1,21 +1,24 @@
import * as InstanceState from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { InstanceBootstrap } from "@/project/bootstrap"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { Effect, Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth" import { Authorization } from "../auth"
import { markInstanceForReload } from "./lifecycle" import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/project" const root = "/project"
const UpdatePayload = Schema.Struct({
name: Schema.optional(Schema.String),
icon: Schema.optional(Project.Info.fields.icon),
commands: Schema.optional(Project.Info.fields.commands),
})
export const ProjectApi = HttpApi.make("project") export const ProjectApi = HttpApi.make("project")
.add( .add(
HttpApiGroup.make("project") HttpApiGroup.make("project")
.add( .add(
HttpApiEndpoint.get("list", root, { HttpApiEndpoint.get("list", root, {
success: Schema.Array(Project.Info), success: described(Schema.Array(Project.Info), "List of projects"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "project.list", identifier: "project.list",
@@ -24,7 +27,7 @@ export const ProjectApi = HttpApi.make("project")
}), }),
), ),
HttpApiEndpoint.get("current", `${root}/current`, { HttpApiEndpoint.get("current", `${root}/current`, {
success: Project.Info, success: described(Project.Info, "Current project information"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "project.current", identifier: "project.current",
@@ -33,7 +36,7 @@ export const ProjectApi = HttpApi.make("project")
}), }),
), ),
HttpApiEndpoint.post("initGit", `${root}/git/init`, { HttpApiEndpoint.post("initGit", `${root}/git/init`, {
success: Project.Info, success: described(Project.Info, "Project information after git initialization"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "project.initGit", identifier: "project.initGit",
@@ -43,8 +46,9 @@ export const ProjectApi = HttpApi.make("project")
), ),
HttpApiEndpoint.patch("update", `${root}/:projectID`, { HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID }, params: { projectID: ProjectID },
payload: Project.UpdatePayload, payload: UpdatePayload,
success: Project.Info, success: described(Project.Info, "Updated project information"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "project.update", identifier: "project.update",
@@ -59,6 +63,7 @@ export const ProjectApi = HttpApi.make("project")
description: "Experimental HttpApi project routes.", description: "Experimental HttpApi project routes.",
}), }),
) )
.middleware(InstanceContextMiddleware)
.middleware(Authorization), .middleware(Authorization),
) )
.annotateMerge( .annotateMerge(
@@ -68,40 +73,3 @@ export const ProjectApi = HttpApi.make("project")
description: "Experimental HttpApi surface for selected instance routes.", description: "Experimental HttpApi surface for selected instance routes.",
}), }),
) )
export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
})
const current = Effect.fn("ProjectHttpApi.current")(function* () {
return (yield* InstanceState.context).project
})
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
const ctx = yield* InstanceState.context
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
return next
yield* markInstanceForReload(ctx, {
directory: ctx.directory,
worktree: ctx.directory,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return next
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
})
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
}),
)
@@ -0,0 +1,74 @@
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/provider"
export const ProviderApi = HttpApi.make("provider")
.add(
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
success: described(Provider.ListResult, "List of providers"),
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.list",
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
success: described(ProviderAuth.Methods, "Provider auth methods"),
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.auth",
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
payload: ProviderAuth.AuthorizeInput,
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.authorize",
summary: "Start OAuth authorization",
description: "Start the OAuth authorization flow for a provider.",
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
payload: ProviderAuth.CallbackInput,
success: described(Schema.Boolean, "OAuth callback processed successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.callback",
summary: "Handle OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "provider",
description: "Experimental HttpApi provider routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,121 @@
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/pty"
export const Params = Schema.Struct({ ptyID: PtyID })
export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) })
export const ShellItem = Schema.Struct({
path: Schema.String,
name: Schema.String,
acceptable: Schema.Boolean,
})
export const PtyPaths = {
shells: `${root}/shells`,
list: root,
create: root,
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connect: `${root}/:ptyID/connect`,
} as const
export const PtyApi = HttpApi.make("pty")
.add(
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("shells", PtyPaths.shells, { success: described(Schema.Array(ShellItem), "List of shells") }).annotateMerge(
OpenApi.annotations({
identifier: "pty.shells",
summary: "List available shells",
description: "Get a list of available shells on the system.",
}),
),
HttpApiEndpoint.get("list", PtyPaths.list, { success: described(Schema.Array(Pty.Info), "List of sessions") }).annotateMerge(
OpenApi.annotations({
identifier: "pty.list",
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, {
payload: Pty.CreateInput,
success: described(Pty.Info, "Created session"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.create",
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
}),
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: described(Pty.Info, "Session info"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: described(Pty.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: described(Schema.Boolean, "Session removed"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
success: described(Schema.Boolean, "Connected session"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
@@ -1,17 +1,24 @@
import { Question } from "@/question" import { Question } from "@/question"
import { QuestionID } from "@/question/schema" import { QuestionID } from "@/question/schema"
import { Effect, Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth" import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/question" const root = "/question"
const ReplyPayload = Schema.Struct({
answers: Schema.Array(Question.Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
})
export const QuestionApi = HttpApi.make("question") export const QuestionApi = HttpApi.make("question")
.add( .add(
HttpApiGroup.make("question") HttpApiGroup.make("question")
.add( .add(
HttpApiEndpoint.get("list", root, { HttpApiEndpoint.get("list", root, {
success: Schema.Array(Question.Request), success: described(Schema.Array(Question.Request), "List of pending questions"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "question.list", identifier: "question.list",
@@ -21,8 +28,9 @@ export const QuestionApi = HttpApi.make("question")
), ),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID }, params: { requestID: QuestionID },
payload: Question.Reply, payload: ReplyPayload,
success: Schema.Boolean, success: described(Schema.Boolean, "Question answered successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "question.reply", identifier: "question.reply",
@@ -32,7 +40,8 @@ export const QuestionApi = HttpApi.make("question")
), ),
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
params: { requestID: QuestionID }, params: { requestID: QuestionID },
success: Schema.Boolean, success: described(Schema.Boolean, "Question rejected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "question.reject", identifier: "question.reject",
@@ -47,6 +56,7 @@ export const QuestionApi = HttpApi.make("question")
description: "Question routes.", description: "Question routes.",
}), }),
) )
.middleware(InstanceContextMiddleware)
.middleware(Authorization), .middleware(Authorization),
) )
.annotateMerge( .annotateMerge(
@@ -56,31 +66,3 @@ export const QuestionApi = HttpApi.make("question")
description: "Effect HttpApi surface for instance routes.", description: "Effect HttpApi surface for instance routes.",
}), }),
) )
export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
Effect.gen(function* () {
const svc = yield* Question.Service
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
yield* svc.reject(ctx.params.requestID)
return true
})
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
}),
)
@@ -0,0 +1,428 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { NonNegativeInt } from "@/util/schema"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/session"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
export const ListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
scope: Schema.optional(Schema.Literals(["project"])),
path: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
})
export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
export const MessagesQuery = Schema.Struct({
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
before: Schema.optional(Schema.String),
})
export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(NonNegativeInt),
}),
),
})
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
export const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
messageID: MessageID,
})
export const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
auto: Schema.optional(Schema.Boolean),
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
})
export const SessionPaths = {
list: root,
status: `${root}/status`,
get: `${root}/:sessionID`,
children: `${root}/:sessionID/children`,
todo: `${root}/:sessionID/todo`,
diff: `${root}/:sessionID/diff`,
messages: `${root}/:sessionID/message`,
message: `${root}/:sessionID/message/:messageID`,
create: root,
remove: `${root}/:sessionID`,
update: `${root}/:sessionID`,
fork: `${root}/:sessionID/fork`,
abort: `${root}/:sessionID/abort`,
share: `${root}/:sessionID/share`,
init: `${root}/:sessionID/init`,
summarize: `${root}/:sessionID/summarize`,
prompt: `${root}/:sessionID/message`,
promptAsync: `${root}/:sessionID/prompt_async`,
command: `${root}/:sessionID/command`,
shell: `${root}/:sessionID/shell`,
revert: `${root}/:sessionID/revert`,
unrevert: `${root}/:sessionID/unrevert`,
permissions: `${root}/:sessionID/permissions/:permissionID`,
deleteMessage: `${root}/:sessionID/message/:messageID`,
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
} as const
export const SessionApi = HttpApi.make("session")
.add(
HttpApiGroup.make("session")
.add(
HttpApiEndpoint.get("list", SessionPaths.list, {
query: ListQuery,
success: described(Schema.Array(Session.Info), "List of sessions"),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.list",
summary: "List sessions",
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
success: described(StatusMap, "Get session status"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.status",
summary: "Get session status",
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
}),
),
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
}),
),
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
success: described(Schema.Array(Session.Info), "List of children"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.children",
summary: "Get session children",
description: "Retrieve all child sessions that were forked from the specified parent session.",
}),
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: described(Schema.Array(Todo.Info), "Todo list"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
}),
),
HttpApiEndpoint.get("diff", SessionPaths.diff, {
params: { sessionID: SessionID },
query: DiffQuery,
success: described(Schema.Array(Snapshot.FileDiff), "Successfully retrieved diff"),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
summary: "Get message diff",
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
}),
),
HttpApiEndpoint.get("messages", SessionPaths.messages, {
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
summary: "Get session messages",
description: "Retrieve all messages in a session, including user prompts and AI responses.",
}),
),
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
summary: "Get message",
description: "Retrieve a specific message from a session by its message ID.",
}),
),
HttpApiEndpoint.post("create", SessionPaths.create, {
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: described(Session.Info, "Successfully created session"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.create",
summary: "Create session",
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
}),
),
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
summary: "Delete session",
description: "Delete a session and permanently remove all associated data, including messages and history.",
}),
),
HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID },
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
summary: "Update session",
description: "Update properties of an existing session, such as title or other metadata.",
}),
),
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
payload: ForkPayload,
success: described(Session.Info, "200"),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
summary: "Fork session",
description: "Create a new session by forking an existing session at a specific message point.",
}),
),
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Aborted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.abort",
summary: "Abort session",
description: "Abort an active session and stop any ongoing AI processing or command execution.",
}),
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
payload: InitPayload,
success: described(Schema.Boolean, "200"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.init",
summary: "Initialize session",
description:
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
}),
),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
summary: "Share session",
description: "Create a shareable link for a session, allowing others to view the conversation.",
}),
),
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
summary: "Unshare session",
description: "Remove the shareable link for a session, making it private again.",
}),
),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID },
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
summary: "Summarize session",
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
}),
),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
summary: "Send message",
description: "Create and send a new message to a session, streaming the AI response.",
}),
),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt_async",
summary: "Send async message",
description:
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
}),
),
HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID },
payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
summary: "Send command",
description: "Send a new command to a session for execution by the AI assistant.",
}),
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.shell",
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
}),
),
HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID },
payload: RevertPayload,
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.revert",
summary: "Revert message",
description:
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
}),
),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unrevert",
summary: "Restore reverted messages",
description: "Restore all previously reverted messages in a session.",
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
summary: "Respond to permission",
description: "Approve or deny a permission request from the AI assistant.",
deprecated: true,
}),
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(Schema.Boolean, "Successfully deleted message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.deleteMessage",
summary: "Delete message",
description:
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
}),
),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
success: described(Schema.Boolean, "Successfully deleted part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "part.delete",
description: "Delete a part from a message.",
}),
),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "part.update",
description: "Update a part in a message.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
description: "Experimental HttpApi session routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,90 @@
import { NonNegativeInt } from "@/util/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/sync"
export const ReplayEvent = Schema.Struct({
id: Schema.String,
aggregateID: Schema.String,
seq: NonNegativeInt,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
})
export const ReplayPayload = Schema.Struct({
directory: Schema.String,
events: Schema.NonEmptyArray(ReplayEvent),
})
export const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
})
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({
id: Schema.String,
aggregate_id: Schema.String,
seq: NonNegativeInt,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
})
export const SyncPaths = {
start: `${root}/start`,
replay: `${root}/replay`,
history: `${root}/history`,
} as const
export const SyncApi = HttpApi.make("sync")
.add(
HttpApiGroup.make("sync")
.add(
HttpApiEndpoint.post("start", SyncPaths.start, {
success: described(Schema.Boolean, "Workspace sync started"),
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.start",
summary: "Start workspace sync",
description: "Start sync loops for workspaces in the current project that have active sessions.",
}),
),
HttpApiEndpoint.post("replay", SyncPaths.replay, {
payload: ReplayPayload,
success: described(ReplayResponse, "Replayed sync events"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.replay",
summary: "Replay sync events",
description: "Validate and replay a complete sync event history.",
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: described(Schema.Array(HistoryEvent), "Sync events"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.history.list",
summary: "List sync events",
description:
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "sync",
description: "Experimental HttpApi sync routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,164 @@
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/tui"
export const CommandPayload = Schema.Struct({ command: Schema.String })
export const TuiRequestPayload = Schema.Struct({
path: Schema.String,
body: Schema.Unknown,
})
const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" })
const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" })
const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" })
const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" })
export const TuiPublishPayload = Schema.Union([EventTuiPromptAppend, EventTuiCommandExecute, EventTuiToastShow, EventTuiSessionSelect])
export const TuiPaths = {
appendPrompt: `${root}/append-prompt`,
openHelp: `${root}/open-help`,
openSessions: `${root}/open-sessions`,
openThemes: `${root}/open-themes`,
openModels: `${root}/open-models`,
submitPrompt: `${root}/submit-prompt`,
clearPrompt: `${root}/clear-prompt`,
executeCommand: `${root}/execute-command`,
showToast: `${root}/show-toast`,
publish: `${root}/publish`,
selectSession: `${root}/select-session`,
controlNext: `${root}/control/next`,
controlResponse: `${root}/control/response`,
} as const
export const TuiApi = HttpApi.make("tui")
.add(
HttpApiGroup.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
payload: TuiEvent.PromptAppend.properties,
success: described(Schema.Boolean, "Prompt processed successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.appendPrompt",
summary: "Append TUI prompt",
description: "Append prompt to the TUI.",
}),
),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: described(Schema.Boolean, "Help dialog opened successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openHelp",
summary: "Open help dialog",
description: "Open the help dialog in the TUI to display user assistance information.",
}),
),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: described(Schema.Boolean, "Session dialog opened successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openSessions",
summary: "Open sessions dialog",
description: "Open the session dialog.",
}),
),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: described(Schema.Boolean, "Theme dialog opened successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openThemes",
summary: "Open themes dialog",
description: "Open the theme dialog.",
}),
),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: described(Schema.Boolean, "Model dialog opened successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openModels",
summary: "Open models dialog",
description: "Open the model dialog.",
}),
),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: described(Schema.Boolean, "Prompt submitted successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.submitPrompt",
summary: "Submit TUI prompt",
description: "Submit the prompt.",
}),
),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: described(Schema.Boolean, "Prompt cleared successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.clearPrompt",
summary: "Clear TUI prompt",
description: "Clear the prompt.",
}),
),
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
payload: CommandPayload,
success: described(Schema.Boolean, "Command executed successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.executeCommand",
summary: "Execute TUI command",
description: "Execute a TUI command.",
}),
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
payload: TuiEvent.ToastShow.properties,
success: described(Schema.Boolean, "Toast notification shown successfully"),
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.showToast",
summary: "Show TUI toast",
description: "Show a toast notification in the TUI.",
}),
),
HttpApiEndpoint.post("publish", TuiPaths.publish, {
payload: TuiPublishPayload,
success: described(Schema.Boolean, "Event published successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.publish",
summary: "Publish TUI event",
description: "Publish a TUI event.",
}),
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",
summary: "Select session",
description: "Navigate the TUI to display the specified session.",
}),
),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: described(TuiRequestPayload, "Next TUI request") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.next",
summary: "Get next TUI request",
description: "Retrieve the next TUI request from the queue for processing.",
}),
),
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
payload: Schema.Unknown,
success: described(Schema.Boolean, "Response submitted successfully"),
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.response",
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,103 @@
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { NonNegativeInt } from "@/util/schema"
import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/experimental/workspace"
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
export const SessionRestorePayload = Schema.Struct(
Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]),
)
export const SessionRestoreResponse = Schema.Struct({
total: NonNegativeInt,
})
export const WorkspacePaths = {
adaptors: `${root}/adaptor`,
list: root,
status: `${root}/status`,
remove: `${root}/:id`,
sessionRestore: `${root}/:id/session-restore`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
.add(
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.adaptor.list",
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
success: described(Schema.Array(Workspace.Info), "Workspaces"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.list",
summary: "List workspaces",
description: "List all workspaces.",
}),
),
HttpApiEndpoint.post("create", WorkspacePaths.list, {
payload: CreatePayload,
success: described(Workspace.Info, "Workspace created"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.create",
summary: "Create workspace",
description: "Create a workspace for the current project.",
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.status",
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
}),
),
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
params: { id: Workspace.Info.fields.id },
success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.remove",
summary: "Remove workspace",
description: "Remove an existing workspace.",
}),
),
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
params: { id: Workspace.Info.fields.id },
payload: SessionRestorePayload,
success: described(SessionRestoreResponse, "Session replay started"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.sessionRestore",
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,34 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForDisposal } from "../lifecycle"
export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) =>
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
const get = Effect.fn("ConfigHttpApi.get")(function* () {
return yield* configSvc.get()
})
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
yield* configSvc.update(ctx.payload, { dispose: false })
yield* markInstanceForDisposal(yield* InstanceState.context)
return ctx.payload
})
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const providers = yield* providerSvc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
}),
)
@@ -0,0 +1,34 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { LogInput } from "../groups/control"
export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
const logger = Log.create({ service: ctx.payload.service })
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
return true
})
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
}),
)
@@ -0,0 +1,155 @@
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree"
import { Effect, Option } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental"
export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) =>
Effect.gen(function* () {
const account = yield* Account.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
return {
consoleManagedProviders: state.consoleManagedProviders,
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
})
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
const [groups, active] = yield* Effect.all(
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
const info = Option.getOrUndefined(active)
return {
orgs: groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
),
}
})
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
payload: typeof ConsoleSwitchPayload.Type
}) {
yield* account
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
const list = yield* registry.tools({
providerID: ctx.query.provider,
modelID: ctx.query.model,
agent: yield* agents.get(yield* agents.defaultAgent()),
})
return list.map((item) => ({
id: item.id,
description: item.description,
parameters: EffectZod.toJsonSchema(item.parameters),
}))
})
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
return yield* registry.ids()
})
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
const ctx = yield* InstanceState.context
return yield* project.sandboxes(ctx.project.id)
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
}) {
return yield* worktreeSvc.create(ctx.payload)
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
payload: Worktree.ResetInput
}) {
yield* worktreeSvc.reset(ctx.payload)
return true
})
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
return HttpServerResponse.jsonUnsafe(list, {
headers:
sessions.length > limit && list.length > 0
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
: undefined,
})
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
return handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource)
}),
)
@@ -0,0 +1,54 @@
import * as InstanceState from "@/effect/instance-state"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
return (yield* ripgrep
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
.pipe(Effect.orDie)).items
})
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) {
return yield* svc.search({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
})
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
return []
})
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
return yield* svc.list(ctx.query.path)
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return yield* svc.read(ctx.query.path)
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return yield* svc.status()
})
return handlers
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status)
}),
)
@@ -0,0 +1,156 @@
import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { Installation } from "@/installation"
import { Instance } from "@/project/instance"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Queue, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { RootHttpApi } from "../api"
import { GlobalUpgradeInput } from "../groups/global"
const log = Log.create({ service: "server" })
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
function parseBody(body: string) {
try {
return JSON.parse(body || "{}") as unknown
} catch {
return undefined
}
}
function eventResponse() {
log.info("global event connected")
const events = Stream.callback<GlobalBusEvent>((queue) => {
const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
return Effect.acquireRelease(
Effect.sync(() => GlobalBus.on("event", handler)),
() => Effect.sync(() => GlobalBus.off("event", handler)),
)
})
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })),
)
return HttpServerResponse.stream(
Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}
export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) =>
Effect.gen(function* () {
const config = yield* Config.Service
const installation = yield* Installation.Service
const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion }
})
const event = Effect.fn("GlobalHttpApi.event")(function* () {
return eventResponse()
})
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
return yield* config.getGlobal()
})
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
return yield* config.updateGlobal(ctx.payload)
})
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
yield* Effect.promise(() => Instance.disposeAll())
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.disposed", properties: {} },
})
return true
})
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
const method = yield* installation.method()
if (method === "unknown") {
return {
status: 400,
body: { success: false as const, error: "Unknown installation method" },
}
}
const target = ctx.payload.target || (yield* installation.latest(method))
const result = yield* installation.upgrade(method, target).pipe(
Effect.as({ status: 200, body: { success: true as const, version: target } }),
Effect.catch((err) =>
Effect.succeed({
status: 500,
body: {
success: false as const,
error: err instanceof Error ? err.message : String(err),
},
}),
),
)
if (!result.body.success) return result
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return result
})
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const json = parseBody(body)
if (json === undefined) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
Effect.map((payload) => ({ valid: true as const, payload })),
Effect.catch(() => Effect.succeed({ valid: false as const })),
)
if (!payload.valid) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const result = yield* upgrade({ payload: payload.payload })
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
})
return handlers
.handle("health", health)
.handleRaw("event", event)
.handle("configGet", configGet)
.handle("configUpdate", configUpdate)
.handle("dispose", dispose)
.handleRaw("upgrade", upgradeRaw)
}),
)
@@ -0,0 +1,79 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import * as InstanceState from "@/effect/instance-state"
import { Format } from "@/format"
import { Global } from "@opencode-ai/core/global"
import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForDisposal } from "../lifecycle"
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const command = yield* Command.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const skill = yield* Skill.Service
const vcs = yield* Vcs.Service
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
yield* markInstanceForDisposal(yield* InstanceState.context)
return true
})
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
const ctx = yield* InstanceState.context
return {
home: Global.Path.home,
state: Global.Path.state,
config: Global.Path.config,
worktree: ctx.worktree,
directory: ctx.directory,
}
})
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
return { branch, default_branch }
})
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
return yield* agent.list()
})
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
return yield* skill.all()
})
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
return yield* lsp.status()
})
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
return yield* format.status()
})
return handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter)
}),
)
@@ -0,0 +1,68 @@
import { MCP } from "@/mcp"
import { Effect, Schema } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
const status = Effect.fn("McpHttpApi.status")(function* () {
return yield* mcp.status()
})
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
return yield* Schema.decodeUnknownEffect(StatusMap)(
"status" in result ? { [ctx.payload.name]: result } : result,
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.startAuth(ctx.params.name)
})
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
params: { name: string }
payload: typeof AuthCallbackPayload.Type
}) {
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
})
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.authenticate(ctx.params.name)
})
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
yield* mcp.removeAuth(ctx.params.name)
return { success: true as const }
})
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
yield* mcp.connect(ctx.params.name)
return true
})
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
yield* mcp.disconnect(ctx.params.name)
return true
})
return handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
.handle("authCallback", authCallback)
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect)
}),
)
@@ -0,0 +1,29 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) =>
Effect.gen(function* () {
const svc = yield* Permission.Service
const list = Effect.fn("PermissionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
})
return true
})
return handlers.handle("list", list).handle("reply", reply)
}),
)
@@ -0,0 +1,46 @@
import { AppRuntime } from "@/effect/app-runtime"
import * as InstanceState from "@/effect/instance-state"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForReload } from "../lifecycle"
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
})
const current = Effect.fn("ProjectHttpApi.current")(function* () {
return (yield* InstanceState.context).project
})
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
const ctx = yield* InstanceState.context
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
return next
yield* markInstanceForReload(ctx, {
directory: ctx.directory,
worktree: ctx.directory,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return next
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
})
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
}),
)
@@ -0,0 +1,89 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) =>
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
const svc = yield* ProviderAuth.Service
const list = Effect.fn("ProviderHttpApi.list")(function* () {
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value
}
const connected = yield* provider.list()
const providers = Object.assign(
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
})
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.AuthorizeInput
}) {
return yield* svc
.authorize({
providerID: ctx.params.providerID,
method: ctx.payload.method,
inputs: ctx.payload.inputs,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
const result = yield* authorize({ params: ctx.params, payload })
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
return HttpServerResponse.jsonUnsafe(result)
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.CallbackInput
}) {
yield* svc
.callback({
providerID: ctx.params.providerID,
method: ctx.payload.method,
code: ctx.payload.code,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
return handlers
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback)
}),
)
@@ -0,0 +1,118 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
const info = yield* pty.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID)
return true
})
return handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
}),
)
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const pty = yield* Pty.Service
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)))
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
)
@@ -0,0 +1,33 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) =>
Effect.gen(function* () {
const svc = yield* Question.Service
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
yield* svc.reject(ctx.params.requestID)
return true
})
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
}),
)
@@ -6,7 +6,6 @@ import { Command } from "@/command"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction" import { SessionCompaction } from "@/session/compaction"
@@ -18,422 +17,27 @@ import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo" import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema" import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot" import { NotFoundError } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { Effect, Schema, SchemaGetter, Struct } from "effect" import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
HttpApi, import { InstanceHttpApi } from "../api"
HttpApiBuilder, import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session"
HttpApiEndpoint,
HttpApiError,
HttpApiGroup,
HttpApiSchema,
OpenApi,
} from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
const root = "/session"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const ListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
scope: Schema.optional(Schema.Literals(["project"])),
path: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
})
const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
const MessagesQuery = Schema.Struct({
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
before: Schema.optional(Schema.String),
})
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(Schema.Number),
}),
),
}).annotate({ identifier: "SessionUpdateInput" })
const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
identifier: "SessionForkInput",
})
const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
messageID: MessageID,
}).annotate({ identifier: "SessionInitInput" })
const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
auto: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "SessionSummarizeInput" })
const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({
identifier: "SessionPromptInput",
})
const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({
identifier: "SessionCommandInput",
})
const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({
identifier: "SessionShellInput",
})
const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({
identifier: "SessionRevertInput",
})
const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
}).annotate({ identifier: "SessionPermissionResponseInput" })
export const SessionPaths = { const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
list: root, self.pipe(
status: `${root}/status`, Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
get: `${root}/:sessionID`, Effect.catchDefect((error) =>
children: `${root}/:sessionID/children`, NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
todo: `${root}/:sessionID/todo`, ),
diff: `${root}/:sessionID/diff`,
messages: `${root}/:sessionID/message`,
message: `${root}/:sessionID/message/:messageID`,
create: root,
remove: `${root}/:sessionID`,
update: `${root}/:sessionID`,
fork: `${root}/:sessionID/fork`,
abort: `${root}/:sessionID/abort`,
share: `${root}/:sessionID/share`,
init: `${root}/:sessionID/init`,
summarize: `${root}/:sessionID/summarize`,
prompt: `${root}/:sessionID/message`,
promptAsync: `${root}/:sessionID/prompt_async`,
command: `${root}/:sessionID/command`,
shell: `${root}/:sessionID/shell`,
revert: `${root}/:sessionID/revert`,
unrevert: `${root}/:sessionID/unrevert`,
permissions: `${root}/:sessionID/permissions/:permissionID`,
deleteMessage: `${root}/:sessionID/message/:messageID`,
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
} as const
export const SessionApi = HttpApi.make("session")
.add(
HttpApiGroup.make("session")
.add(
HttpApiEndpoint.get("list", SessionPaths.list, {
query: ListQuery,
success: Schema.Array(Session.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.list",
summary: "List sessions",
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
success: StatusMap,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.status",
summary: "Get session status",
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
}),
),
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
}),
),
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
success: Schema.Array(Session.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.children",
summary: "Get session children",
description: "Retrieve all child sessions that were forked from the specified parent session.",
}),
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: Schema.Array(Todo.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
}),
),
HttpApiEndpoint.get("diff", SessionPaths.diff, {
params: { sessionID: SessionID },
query: DiffQuery,
success: Schema.Array(Snapshot.FileDiff),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
summary: "Get message diff",
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
}),
),
HttpApiEndpoint.get("messages", SessionPaths.messages, {
params: { sessionID: SessionID },
query: MessagesQuery,
success: Schema.Array(MessageV2.WithParts),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
summary: "Get session messages",
description: "Retrieve all messages in a session, including user prompts and AI responses.",
}),
),
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
summary: "Get message",
description: "Retrieve a specific message from a session by its message ID.",
}),
),
HttpApiEndpoint.post("create", SessionPaths.create, {
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: Session.Info,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.create",
summary: "Create session",
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
}),
),
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
summary: "Delete session",
description: "Delete a session and permanently remove all associated data, including messages and history.",
}),
),
HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID },
payload: UpdatePayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
summary: "Update session",
description: "Update properties of an existing session, such as title or other metadata.",
}),
),
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
payload: ForkPayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
summary: "Fork session",
description: "Create a new session by forking an existing session at a specific message point.",
}),
),
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.abort",
summary: "Abort session",
description: "Abort an active session and stop any ongoing AI processing or command execution.",
}),
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
payload: InitPayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.init",
summary: "Initialize session",
description:
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
}),
),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
summary: "Share session",
description: "Create a shareable link for a session, allowing others to view the conversation.",
}),
),
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
summary: "Unshare session",
description: "Remove the shareable link for a session, making it private again.",
}),
),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID },
payload: SummarizePayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
summary: "Summarize session",
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
}),
),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
summary: "Send message",
description: "Create and send a new message to a session, streaming the AI response.",
}),
),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt_async",
summary: "Send async message",
description:
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
}),
),
HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID },
payload: CommandPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
summary: "Send command",
description: "Send a new command to a session for execution by the AI assistant.",
}),
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
payload: ShellPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.shell",
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
}),
),
HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID },
payload: RevertPayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.revert",
summary: "Revert message",
description:
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
}),
),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unrevert",
summary: "Restore reverted messages",
description: "Restore all previously reverted messages in a session.",
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
payload: PermissionResponsePayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
summary: "Respond to permission",
description: "Approve or deny a permission request from the AI assistant.",
deprecated: true,
}),
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.deleteMessage",
summary: "Delete message",
description:
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
}),
),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "part.delete",
description: "Delete a part from a message.",
}),
),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
payload: MessageV2.Part,
success: MessageV2.Part,
}).annotateMerge(
OpenApi.annotations({
identifier: "part.update",
description: "Update a part in a message.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
description: "Experimental HttpApi session routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
) )
export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) => export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const statusSvc = yield* SessionStatus.Service const statusSvc = yield* SessionStatus.Service
@@ -462,7 +66,7 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
}) })
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session.get(ctx.params.sessionID) return yield* mapNotFound(session.get(ctx.params.sessionID))
}) })
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) { const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -484,44 +88,47 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
params: { sessionID: SessionID } params: { sessionID: SessionID }
query: typeof MessagesQuery.Type query: typeof MessagesQuery.Type
}) { }) {
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) return yield* mapNotFound(Effect.gen(function* () {
if (ctx.query.before) { if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
const before = ctx.query.before if (ctx.query.before) {
yield* Effect.try({ const before = ctx.query.before
try: () => MessageV2.cursor.decode(before), yield* Effect.try({
catch: () => new HttpApiError.BadRequest({}), try: () => MessageV2.cursor.decode(before),
}) catch: () => new HttpApiError.BadRequest({}),
} })
if (ctx.query.limit === undefined || ctx.query.limit === 0) { }
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* session.get(ctx.params.sessionID)
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
yield* session.get(ctx.params.sessionID) yield* session.get(ctx.params.sessionID)
return yield* session.messages({ sessionID: ctx.params.sessionID }) const page = MessageV2.page({
} sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
const page = MessageV2.page({ const request = yield* HttpServerRequest.HttpServerRequest
sessionID: ctx.params.sessionID, const url = new URL(request.url, "http://localhost")
limit: ctx.query.limit, url.searchParams.set("limit", ctx.query.limit.toString())
before: ctx.query.before, url.searchParams.set("before", page.cursor)
}) return HttpServerResponse.jsonUnsafe(page.items, {
if (!page.cursor) return page.items headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
const request = yield* HttpServerRequest.HttpServerRequest Link: `<${url.toString()}>; rel="next"`,
const url = new URL(request.url, "http://localhost") "X-Next-Cursor": page.cursor,
url.searchParams.set("limit", ctx.query.limit.toString()) },
url.searchParams.set("before", page.cursor) })
return HttpServerResponse.jsonUnsafe(page.items, { }))
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
}) })
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: { const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID } params: { sessionID: SessionID; messageID: MessageID }
}) { }) {
return yield* Effect.sync(() => return yield* mapNotFound(
MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })),
) )
}) })
@@ -0,0 +1,54 @@
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync"
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
return true
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
SyncEvent.replayAll(events)
return { sessionID: events[0].aggregateID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
})
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
}),
)
@@ -0,0 +1,134 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../../tui"
import { InstanceHttpApi } from "../api"
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
const commandAliases = {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
} as const
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
yield* publishCommand("help.show")
return true
})
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
yield* publishCommand("session.list")
return true
})
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
yield* publishCommand("session.list")
return true
})
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
yield* publishCommand("model.list")
return true
})
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
yield* publishCommand("prompt.submit")
return true
})
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
yield* publishCommand("prompt.clear")
return true
})
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
return true
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
const row = yield* Effect.sync(() =>
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
),
)
if (!row) return yield* new HttpApiError.NotFound({})
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
return yield* Effect.promise(() => nextTuiRequest())
})
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
submitTuiResponse(ctx.payload)
return true
})
return handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse)
}),
)
@@ -0,0 +1,66 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list((yield* InstanceState.context).project)
})
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.create({
...ctx.payload,
projectID: instance.project.id,
}),
),
)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
}),
),
)
})
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
}),
)
@@ -0,0 +1,191 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema"
import type { Target } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Session } from "@/session/session"
import { ServerProxy } from "@/server/proxy"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Effect, Layer } from "effect"
import type { unhandled } from "effect/Types"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
type HandlerEffect = Effect.Effect<HttpServerResponse.HttpServerResponse, unhandled, never>
export class InstanceContextMiddleware extends HttpApiMiddleware.Service<InstanceContextMiddleware, {
requires: Session.Service
}>()(
"@opencode/ExperimentalHttpApiInstanceContext",
) {}
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
function currentDirectory() {
try {
return Instance.directory
} catch {
return process.cwd()
}
}
function sourceRequest(request: HttpServerRequest.HttpServerRequest) {
if (request.source instanceof Request) return request.source
return new Request(new URL(request.originalUrl, "http://localhost"), {
method: request.method,
headers: request.headers as HeadersInit,
})
}
function requestHeaders(request: HttpServerRequest.HttpServerRequest) {
return sourceRequest(request).headers
}
function writeSocket(write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect<void, unknown>, data: unknown) {
if (data instanceof Blob) {
void data.arrayBuffer().then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void))))
return
}
if (typeof data === "string" || data instanceof Uint8Array) {
Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void)))
return
}
if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void)))
}
function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) {
return Effect.gen(function* () {
const source = sourceRequest(request)
const socket = yield* Effect.orDie(request.upgrade)
const write = yield* socket.writer
const queue: Array<string | Uint8Array> = []
const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source))
remote.binaryType = "arraybuffer"
remote.onopen = () => {
for (const item of queue) remote.send(item)
queue.length = 0
}
remote.onmessage = (event) => writeSocket(write, event.data)
remote.onerror = () => Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void)))
remote.onclose = (event) =>
Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void)))
yield* socket
.runRaw((message) => {
const data = typeof message === "string" ? message : message.slice()
if (remote.readyState === WebSocket.OPEN) {
remote.send(data)
return
}
queue.push(data)
})
.pipe(
Effect.catch(() => Effect.void),
Effect.ensuring(Effect.sync(() => remote.close())),
Effect.orDie,
)
return HttpServerResponse.empty()
})
}
function proxyRemote(
request: HttpServerRequest.HttpServerRequest,
workspace: Workspace.Info,
target: Extract<Target, { type: "remote" }>,
requestURL: URL,
) {
const url = workspaceProxyURL(target.url, requestURL)
const source = sourceRequest(request)
if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url)
return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe(Effect.map(HttpServerResponse.raw))
}
function requestContext() {
return Effect.withFiber<HttpServerRequest.HttpServerRequest, never>((fiber) =>
Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)),
)
}
function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID) {
return Effect.gen(function* () {
const url = new URL(request.url, "http://localhost")
const headers = requestHeaders(request)
const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
const workspaceParam = url.searchParams.get("workspace")
const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined)
const workspace = workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined
if (workspaceID && !workspace && !envWorkspaceID) {
return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, {
status: 500,
contentType: "text/plain; charset=utf-8",
})
}
if (workspace && !isLocalWorkspaceRoute(request.method, url.pathname) && !url.pathname.startsWith("/console") && !envWorkspaceID) {
const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type))
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url)
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: target.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, workspace.id),
)
}
const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory()
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID),
)
})
}
function provideInstanceContext(effect: HandlerEffect) {
return Effect.gen(function* () {
const request = yield* requestContext()
const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost"))
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
return yield* provideRequestContext(effect, request, session?.workspaceID)
})
}
export const instanceContextLayer = Layer.succeed(
InstanceContextMiddleware,
InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)),
)
export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) =>
requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))),
)).layer
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
const disposeAfterResponse = new WeakMap<object, InstanceContext>() const disposeAfterResponse = new WeakMap<object, InstanceContext>()
const reloadAfterResponse = new WeakMap<object, InstanceContext & { next: Parameters<typeof Instance.reload>[0] }>()
export const markInstanceForDisposal = (ctx: InstanceContext) => export const markInstanceForDisposal = (ctx: InstanceContext) =>
HttpEffect.appendPreResponseHandler((request, response) => HttpEffect.appendPreResponseHandler((request, response) =>
@@ -14,27 +13,17 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
) )
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) => export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
HttpEffect.appendPreResponseHandler((request, response) => HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.sync(() => { Effect.as(Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), response),
reloadAfterResponse.set(request.source, { ...ctx, next })
return response
}),
) )
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* effect const response = yield* effect
const request = yield* HttpServerRequest.HttpServerRequest const request = yield* HttpServerRequest.HttpServerRequest
const reload = reloadAfterResponse.get(request.source)
if (reload) {
reloadAfterResponse.delete(request.source)
yield* Effect.promise(() => Instance.restore(reload, () => Instance.reload(reload.next)))
return response
}
const ctx = disposeAfterResponse.get(request.source) const ctx = disposeAfterResponse.get(request.source)
if (!ctx) return response if (!ctx) return response
disposeAfterResponse.delete(request.source) disposeAfterResponse.delete(request.source)
yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())) yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
return response return response
}) })
@@ -1,157 +0,0 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/provider"
export const ProviderApi = HttpApi.make("provider")
.add(
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
success: Provider.ListResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.list",
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
success: ProviderAuth.Methods,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.auth",
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
payload: ProviderAuth.AuthorizeInput,
success: Schema.UndefinedOr(ProviderAuth.Authorization),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.authorize",
summary: "Start OAuth authorization",
description: "Start the OAuth authorization flow for a provider.",
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
payload: ProviderAuth.CallbackInput,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.callback",
summary: "Handle OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "provider",
description: "Experimental HttpApi provider routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
const svc = yield* ProviderAuth.Service
const list = Effect.fn("ProviderHttpApi.list")(function* () {
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
const connected = yield* provider.list()
const providers = Object.assign(
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
})
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.AuthorizeInput
}) {
const result = yield* svc
.authorize({
providerID: ctx.params.providerID,
method: ctx.payload.method,
inputs: ctx.payload.inputs,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return result
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
const result = yield* authorize({ params: ctx.params, payload })
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
return HttpServerResponse.jsonUnsafe(result)
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.CallbackInput
}) {
yield* svc
.callback({
providerID: ctx.params.providerID,
method: ctx.payload.method,
code: ctx.payload.code,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
return handlers
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback)
}),
)
@@ -1,242 +0,0 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
import { Effect, Schema } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { Authorization } from "./auth"
const root = "/pty"
const Params = Schema.Struct({
ptyID: PtyID,
})
const CursorQuery = Schema.Struct({
cursor: Schema.optional(Schema.String),
})
const ShellItem = Schema.Struct({
path: Schema.String,
name: Schema.String,
acceptable: Schema.Boolean,
})
export const PtyPaths = {
shells: `${root}/shells`,
list: root,
create: root,
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connect: `${root}/:ptyID/connect`,
} as const
export const PtyApi = HttpApi.make("pty")
.add(
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("shells", PtyPaths.shells, {
success: Schema.Array(ShellItem),
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.shells",
summary: "List available shells",
description: "Get a list of available shells on the system.",
}),
),
HttpApiEndpoint.get("list", PtyPaths.list, {
success: Schema.Array(Pty.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.list",
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, {
payload: Pty.CreateInput,
success: Pty.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.create",
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
}),
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "pty",
description: "Experimental HttpApi PTY routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
const info = yield* pty.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID)
return true
})
return handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
}),
)
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const pty = yield* Pty.Service
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
)
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
)
@@ -1,21 +1,5 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi" import { OpenApi } from "effect/unstable/httpapi"
import { ConfigApi } from "./config" import { OpenCodeHttpApi } from "./api"
import { ControlApi } from "./control"
import { EventApi } from "./event"
import { ExperimentalApi } from "./experimental"
import { FileApi } from "./file"
import { GlobalApi } from "./global"
import { InstanceApi } from "./instance"
import { McpApi } from "./mcp"
import { PermissionApi } from "./permission"
import { ProjectApi } from "./project"
import { ProviderApi } from "./provider"
import { PtyApi, PtyConnectApi } from "./pty"
import { QuestionApi } from "./question"
import { SessionApi } from "./session"
import { SyncApi } from "./sync"
import { TuiApi } from "./tui"
import { WorkspaceApi } from "./workspace"
type OpenApiParameter = { type OpenApiParameter = {
name: string name: string
@@ -26,11 +10,12 @@ type OpenApiParameter = {
type OpenApiOperation = { type OpenApiOperation = {
parameters?: OpenApiParameter[] parameters?: OpenApiParameter[]
responses?: Record<string, unknown> responses?: Record<string, OpenApiResponse>
requestBody?: { requestBody?: {
required?: boolean required?: boolean
content?: Record<string, { schema?: OpenApiSchema }> content?: Record<string, { schema?: OpenApiSchema }>
} }
security?: unknown
} }
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>> type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
@@ -38,6 +23,7 @@ type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch
type OpenApiSpec = { type OpenApiSpec = {
components?: { components?: {
schemas?: Record<string, OpenApiSchema> schemas?: Record<string, OpenApiSchema>
securitySchemes?: Record<string, unknown>
} }
paths?: Record<string, OpenApiPathItem> paths?: Record<string, OpenApiPathItem>
} }
@@ -47,16 +33,25 @@ type OpenApiSchema = {
additionalProperties?: OpenApiSchema | boolean additionalProperties?: OpenApiSchema | boolean
allOf?: OpenApiSchema[] allOf?: OpenApiSchema[]
anyOf?: OpenApiSchema[] anyOf?: OpenApiSchema[]
enum?: string[] description?: string
enum?: Array<string | boolean>
items?: OpenApiSchema items?: OpenApiSchema
maximum?: number maximum?: number
minimum?: number minimum?: number
oneOf?: OpenApiSchema[] oneOf?: OpenApiSchema[]
prefixItems?: OpenApiSchema[] prefixItems?: OpenApiSchema[]
properties?: Record<string, OpenApiSchema> properties?: Record<string, OpenApiSchema>
required?: string[]
type?: string type?: string
} }
type OpenApiResponse = {
description?: string
content?: Record<string, { schema?: OpenApiSchema }>
}
// Instance routes use middleware for directory/workspace resolution, but HttpApi
// doesn't surface middleware query params in the spec. Inject them explicitly.
const InstanceQueryParameters = [ const InstanceQueryParameters = [
{ {
name: "directory", name: "directory",
@@ -72,8 +67,9 @@ const InstanceQueryParameters = [
}, },
] satisfies OpenApiParameter[] ] satisfies OpenApiParameter[]
const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"]) // Query schemas describe decoded Effect values, but the generated SDK needs the
const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"]) // public call shape. These keep SDK callers passing numbers/booleans while the
// server still decodes string query params at runtime.
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = { const QueryParameterSchemas = {
@@ -81,60 +77,80 @@ const QueryParameterSchemas = {
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
} satisfies Record<string, OpenApiSchema> } satisfies Record<string, OpenApiSchema>
const LegacyComponentDescriptions = {
LogLevel: "Log level",
ServerConfig: "Server configuration for opencode serve and web commands",
LayoutConfig: "@deprecated Always uses stretch layout.",
} satisfies Record<string, string>
function matchLegacyOpenApi(input: Record<string, unknown>) { function matchLegacyOpenApi(input: Record<string, unknown>) {
const spec = input as OpenApiSpec const spec = input as OpenApiSpec
// Effect's multi-document JSON Schema deduplicator can produce self-referencing
// component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition
// of X itself) when the same AST node appears both as a standalone endpoint
// payload and inside an annotated union arm. Resolve these by inlining the
// actual schema from any parent union that references them.
fixSelfReferencingComponents(spec)
// Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI,
// but the legacy SDK expected plain `T` for optional fields. Strip null
// from all component schemas so both request and response types match.
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema))
}
normalizeComponentNames(spec)
collapseDuplicateComponents(spec)
applyLegacySchemaOverrides(spec)
normalizeComponentDescriptions(spec)
addLegacyErrorSchemas(spec)
delete spec.components?.schemas?.Unauthorized
delete spec.components?.schemas?.EffectHttpApiErrorBadRequest
delete spec.components?.schemas?.EffectHttpApiErrorNotFound
delete spec.components?.schemas?.effect_HttpApiError_BadRequest
delete spec.components?.schemas?.effect_HttpApiError_NotFound
delete spec.components?.securitySchemes
for (const [path, item] of Object.entries(spec.paths ?? {})) { for (const [path, item] of Object.entries(spec.paths ?? {})) {
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
for (const method of ["get", "post", "put", "delete", "patch"] as const) { for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method] const operation = item[method]
if (!operation) continue if (!operation) continue
if (operation.requestBody) { if (operation.requestBody) {
// Hono's generated OpenAPI never marked request bodies as required. Keep
// that SDK surface stable during the HttpApi migration.
delete operation.requestBody.required delete operation.requestBody.required
for (const media of Object.values(operation.requestBody.content ?? {})) { const body = operation.requestBody.content?.["application/json"]
const ref = media.schema?.$ref?.replace("#/components/schemas/", "") if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema))
if (ref && LegacyBodyRefParameters.has(ref)) continue
if (ref && spec.components?.schemas?.[ref]) {
media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref]))
continue
}
if (media.schema) media.schema = normalizeRequestSchema(media.schema)
}
if (path === "/experimental/workspace" && method === "post") { if (path === "/experimental/workspace" && method === "post") {
const properties = operation.requestBody.content?.["application/json"]?.schema?.properties // Workspace creation fields `branch` and `extra` are Schema.NullOr —
// genuinely nullable, not just optional. Re-add the null that the
// component-level strip above removed.
const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace("#/components/schemas/", "")
const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
} }
if (path === "/tui/publish" && method === "post" && spec.components?.schemas) { }
const schema = operation.requestBody.content?.["application/json"]?.schema for (const response of Object.values(operation.responses ?? {})) {
const anyOf = schema?.anyOf for (const content of Object.values(response.content ?? {})) {
if (anyOf?.length === 4) { if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema))
spec.components.schemas.EventTuiPromptAppend = anyOf[0]
spec.components.schemas.EventTuiCommandExecute = anyOf[1]
spec.components.schemas.EventTuiToastShow = anyOf[2]
spec.components.schemas.EventTuiSessionSelect = anyOf[3]
operation.requestBody.content!["application/json"]!.schema = {
anyOf: [
{ $ref: "#/components/schemas/EventTuiPromptAppend" },
{ $ref: "#/components/schemas/EventTuiCommandExecute" },
{ $ref: "#/components/schemas/EventTuiToastShow" },
{ $ref: "#/components/schemas/EventTuiSessionSelect" },
],
}
}
}
if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) {
const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events
if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") {
events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent))
}
} }
} }
// Hono applied auth as runtime middleware outside OpenAPI metadata, so the
// legacy SDK did not expose auth schemes or generated 401 error unions.
delete operation.security
delete operation.responses?.["401"]
normalizeLegacyErrorResponses(operation)
normalizeLegacyOperation(operation, path, method)
if ((path === "/event" || path === "/global/event") && method === "get") { if ((path === "/event" || path === "/global/event") && method === "get") {
// HttpApi has no first-class SSE response schema, and these handlers are
// raw/streaming routes. Document the actual wire protocol explicitly.
operation.responses!["200"] = { operation.responses!["200"] = {
description: "Event stream", description: "Event stream",
content: { content: {
"text/event-stream": { "text/event-stream": {
schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" }, schema: path === "/event" ? { $ref: "#/components/schemas/Event" } : { $ref: "#/components/schemas/GlobalEvent" },
}, },
}, },
} }
@@ -152,40 +168,302 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
return input return input
} }
function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema { function addLegacyErrorSchemas(spec: OpenApiSpec) {
if (!spec.components?.schemas) return
spec.components.schemas.BadRequestError = {
type: "object",
required: ["data", "errors", "success"],
properties: {
data: {},
errors: {
type: "array",
items: {
type: "object",
additionalProperties: {},
},
},
success: { type: "boolean", enum: [false] },
},
}
spec.components.schemas.NotFoundError = {
type: "object",
required: ["name", "data"],
properties: {
name: { type: "string", enum: ["NotFoundError"] },
data: {
type: "object",
required: ["message"],
properties: {
message: { type: "string" },
},
},
},
}
}
function collapseDuplicateComponents(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return
for (const name of Object.keys(schemas)) {
const base = name.replace(/\d+$/, "")
if (base === name || !schemas[base]) continue
if (stableSchema(schemas[name], schemas) !== stableSchema(schemas[base], schemas)) continue
rewriteRefs(spec, name, base)
delete schemas[name]
}
}
function normalizeComponentNames(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return
for (const name of Object.keys(schemas)) {
const next = componentTypeName(name)
if (next === name) continue
if (schemas[next]) {
if (stableSchema(schemas[name], schemas) === stableSchema(schemas[next], schemas)) {
rewriteRefs(spec, name, next)
delete schemas[name]
}
continue
}
schemas[next] = schemas[name]
rewriteRefs(spec, name, next)
delete schemas[name]
}
}
function componentTypeName(name: string) {
if (!name.includes(".")) return name
return name
.split(".")
.filter((part) => !/^\d+$/.test(part))
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join("")
}
function applyLegacySchemaOverrides(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return
if (schemas.AgentConfig) schemas.AgentConfig.additionalProperties = {}
if (schemas.Command?.properties?.template) schemas.Command.properties.template = { type: "string" }
if (schemas.Workspace?.properties) {
schemas.Workspace.properties.branch = nullable(schemas.Workspace.properties.branch)
schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory)
schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra)
}
if (schemas.GlobalSession?.properties?.project) schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project)
const providerOptions = schemas.ProviderConfig?.properties?.options
if (providerOptions) providerOptions.additionalProperties = {}
const model = schemas.ProviderConfig?.properties?.models?.additionalProperties
const variants = typeof model === "object" ? model.properties?.variants?.additionalProperties : undefined
if (variants && typeof variants === "object") variants.additionalProperties = {}
const syncInfo = schemas.SyncEventSessionUpdated?.properties?.data?.properties?.info
if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties)
}
function normalizeComponentDescriptions(spec: OpenApiSpec) {
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions]
if (description) {
schema.description = description
continue
}
delete schema.description
}
}
function makePropertiesNullable(properties: Record<string, OpenApiSchema>) {
for (const [key, value] of Object.entries(properties)) {
if (key === "share" && value.properties?.url) {
value.properties.url = nullable(value.properties.url)
continue
}
if (key === "time" && value.properties) {
makePropertiesNullable(value.properties)
continue
}
properties[key] = nullable(value)
}
}
function nullable(schema: OpenApiSchema): OpenApiSchema {
if (flattenOptions(schema.anyOf ?? schema.oneOf)?.some((item) => item.type === "null")) return schema
return { anyOf: [schema, { type: "null" }] }
}
function stableSchema(input: unknown, schemas: Record<string, OpenApiSchema>): string {
return JSON.stringify(canonicalizeSchema(input, schemas))
}
function canonicalizeSchema(input: unknown, schemas: Record<string, OpenApiSchema>): unknown {
if (Array.isArray(input)) return input.map((item) => canonicalizeSchema(item, schemas))
if (!input || typeof input !== "object") return input
const schema = input as OpenApiSchema
if (schema.$ref) return { $ref: canonicalRef(schema.$ref, schemas) }
return Object.fromEntries(
Object.entries(input)
.filter(([key]) => key !== "description")
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => [key, canonicalizeSchema(value, schemas)]),
)
}
function canonicalRef(ref: string, schemas: Record<string, OpenApiSchema>) {
const name = ref.replace("#/components/schemas/", "")
const base = name.replace(/\d+$/, "")
if (base !== name && schemas[base]) return `#/components/schemas/${base}`
return ref
}
function rewriteRefs(input: unknown, from: string, to: string): void {
if (Array.isArray(input)) {
for (const item of input) rewriteRefs(item, from, to)
return
}
if (!input || typeof input !== "object") return
const schema = input as OpenApiSchema
if (schema.$ref === `#/components/schemas/${from}`) schema.$ref = `#/components/schemas/${to}`
for (const value of Object.values(input)) rewriteRefs(value, from, to)
}
function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
if (operation.responses?.["400"] && isBuiltInErrorResponse(operation.responses["400"], "BadRequest")) {
operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError")
}
if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) {
operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError")
}
}
function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) {
if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"]
if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"]
if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return
const response = operation.responses?.["200"]?.content?.["application/json"]
if (!response) return
response.schema = {
type: "object",
required: ["info", "parts"],
properties: {
info: { $ref: "#/components/schemas/AssistantMessage" },
parts: {
type: "array",
items: { $ref: "#/components/schemas/Part" },
},
},
}
}
function isRefResponse(response: OpenApiResponse, name: string) {
return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}`
}
function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") {
return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`)
}
function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse {
return {
description,
content: {
"application/json": {
schema: { $ref: `#/components/schemas/${name}` },
},
},
}
}
/**
* Fix component schemas that are self-referencing `$ref`s an Effect OpenAPI
* generation bug where annotated union arms that share AST nodes with other
* endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X.
*
* Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf`
* that references the broken component, then inlining that schema.
*/
function fixSelfReferencingComponents(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return
const selfRefs = new Set<string>()
for (const [name, schema] of Object.entries(schemas)) {
if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name)
}
if (selfRefs.size === 0) return
// Find a parent union component whose anyOf/oneOf contains a $ref to the
// broken component — that parent was generated correctly and holds the inline
// schema we need.
for (const [, schema] of Object.entries(schemas)) {
for (const member of schema.anyOf ?? schema.oneOf ?? []) {
const ref = member.$ref?.replace("#/components/schemas/", "")
if (!ref || !selfRefs.has(ref)) continue
// This member's $ref points to a self-referencing component. The member
// itself is just {$ref:...}, so the actual schema must be resolved from
// the union. Since the union component was generated before the
// deduplicator broke things, the inline version lives elsewhere. Generate
// a fresh spec without the transform to get the correct schema.
// Simpler approach: look through all paths for an endpoint that uses this
// schema as a payload (it would have been expanded by the ref-expansion
// logic above if we ran after that, but we run before). Instead, just
// delete the broken component — if it's referenced via $ref elsewhere,
// the ref expansion in the request body loop will inline it anyway.
}
}
// Simplest fix: generate the raw spec (without transform) to get correct schemas
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
const rawSchemas = raw.components?.schemas
if (!rawSchemas) return
for (const name of selfRefs) {
if (rawSchemas[name]) schemas[name] = rawSchemas[name]
}
}
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
const options = flattenOptions(schema.anyOf ?? schema.oneOf) const options = flattenOptions(schema.anyOf ?? schema.oneOf)
if (options) { if (options) {
const withoutNull = options.filter((item) => item.type !== "null") const withoutNull = options.filter((item) => item.type !== "null")
const finite = withoutNull.find((item) => item.type === "number") if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0])
if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" } if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull)
if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0]) if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull)
if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema)
if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema)
} }
if (schema.allOf) { if (schema.allOf) {
if (schema.type) delete schema.allOf const allOf = schema.allOf.map(stripOptionalNull)
else schema.allOf = schema.allOf.map(normalizeRequestSchema) if (schema.type) {
delete schema.allOf
for (const item of allOf) Object.assign(schema, item)
} else {
schema.allOf = allOf
}
} }
if (schema.prefixItems && schema.items) delete schema.prefixItems if (schema.prefixItems && schema.items) delete schema.prefixItems
if (schema.items) schema.items = normalizeRequestSchema(schema.items) if (schema.items) schema.items = stripOptionalNull(schema.items)
if (schema.properties) { if (schema.properties) {
for (const [key, value] of Object.entries(schema.properties)) { for (const [key, value] of Object.entries(schema.properties)) {
schema.properties[key] = normalizeRequestSchema(value) schema.properties[key] = stripOptionalNull(value)
} }
} }
if (schema.additionalProperties && typeof schema.additionalProperties === "object") { if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties) schema.additionalProperties = stripOptionalNull(schema.additionalProperties)
} }
return schema return schema
} }
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined { function isEmptyObjectUnion(schema: OpenApiSchema) {
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) const options = schema.anyOf ?? schema.oneOf
return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema)
} }
function isFiniteNumberOption(schema: OpenApiSchema) { function isBareObjectSchema(schema: OpenApiSchema) {
if (schema.type === "number") return true return schema.type === "object" && !schema.properties && !schema.additionalProperties
return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true }
function isBareArraySchema(schema: OpenApiSchema) {
return schema.type === "array" && !schema.items && !schema.prefixItems
}
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
} }
function normalizeParameter(param: OpenApiParameter, route: string) { function normalizeParameter(param: OpenApiParameter, route: string) {
@@ -205,28 +483,10 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
} }
return return
} }
param.schema = normalizeRequestSchema(param.schema) param.schema = stripOptionalNull(param.schema)
} }
export const PublicApi = HttpApi.make("opencode") export const PublicApi = OpenCodeHttpApi
.addHttpApi(ControlApi)
.addHttpApi(GlobalApi)
.addHttpApi(EventApi)
.addHttpApi(ConfigApi)
.addHttpApi(ExperimentalApi)
.addHttpApi(FileApi)
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(PermissionApi)
.addHttpApi(ProjectApi)
.addHttpApi(ProviderApi)
.addHttpApi(PtyApi)
.addHttpApi(PtyConnectApi)
.addHttpApi(QuestionApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "opencode", title: "opencode",
@@ -1,14 +1,12 @@
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" import { HttpRouter, HttpServer } from "effect/unstable/http"
import { Account } from "@/account/account" import { Account } from "@/account/account"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Command } from "@/command" import { Command } from "@/command"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import * as Observability from "@opencode-ai/core/effect/observability" import * as Observability from "@opencode-ai/core/effect/observability"
import { File } from "@/file" import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep" import { Ripgrep } from "@/file/ripgrep"
@@ -16,8 +14,6 @@ import { Format } from "@/format"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth" import { ProviderAuth } from "@/provider/auth"
@@ -32,131 +28,103 @@ import { Todo } from "@/session/todo"
import { Skill } from "@/skill" import { Skill } from "@/skill"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { authorizationLayer } from "./auth" import { authorizationLayer } from "./auth"
import { ConfigApi, configHandlers } from "./config"
import { ControlApi, controlHandlers } from "./control"
import { eventRoute } from "./event" import { eventRoute } from "./event"
import { FileApi, fileHandlers } from "./file" import { configHandlers } from "./handlers/config"
import { ExperimentalApi, experimentalHandlers } from "./experimental" import { controlHandlers } from "./handlers/control"
import { GlobalApi, globalHandlers } from "./global" import { experimentalHandlers } from "./handlers/experimental"
import { InstanceApi, instanceHandlers } from "./instance" import { fileHandlers } from "./handlers/file"
import { McpApi, mcpHandlers } from "./mcp" import { globalHandlers } from "./handlers/global"
import { PermissionApi, permissionHandlers } from "./permission" import { instanceHandlers } from "./handlers/instance"
import { ProjectApi, projectHandlers } from "./project" import { mcpHandlers } from "./handlers/mcp"
import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty" import { permissionHandlers } from "./handlers/permission"
import { ProviderApi, providerHandlers } from "./provider" import { projectHandlers } from "./handlers/project"
import { QuestionApi, questionHandlers } from "./question" import { providerHandlers } from "./handlers/provider"
import { SessionApi, sessionHandlers } from "./session" import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
import { SyncApi, syncHandlers } from "./sync" import { questionHandlers } from "./handlers/question"
import { TuiApi, tuiHandlers } from "./tui" import { sessionHandlers } from "./handlers/session"
import { WorkspaceApi, workspaceHandlers } from "./workspace" import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer, instanceRouterLayer } from "./instance-context"
import { disposeMiddleware } from "./lifecycle" import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map" import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
auth_token: Schema.optional(Schema.String),
})
const Headers = Schema.Struct({
authorization: Schema.optional(Schema.String),
"x-opencode-directory": Schema.optional(Schema.String),
})
export const context = Context.empty() as Context.Context<unknown> export const context = Context.empty() as Context.Context<unknown>
function decode(input: string) { const runtime = HttpRouter.middleware()(
try { Effect.succeed((effect) =>
return decodeURIComponent(input) Effect.gen(function* () {
} catch { const selected = ServerBackend.select()
return input yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi")))
} return yield* effect
} }),
),
const instance = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
const workspace = query.workspace || undefined
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
})
}),
).layer ).layer
const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers)) const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
const instanceApiRoutes = Layer.mergeAll( Layer.provide([
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)), configHandlers,
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)), experimentalHandlers,
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)), fileHandlers,
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)), instanceHandlers,
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)), mcpHandlers,
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)), projectHandlers,
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers)), ptyHandlers,
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)), questionHandlers,
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)), permissionHandlers,
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)), providerHandlers,
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)), sessionHandlers,
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)), syncHandlers,
HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)), tuiHandlers,
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)), workspaceHandlers,
]),
) )
const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe( const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
Layer.provide(authorizationLayer), const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide(instance), Layer.provide([authorizationLayer, instanceContextLayer]),
) )
export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
.pipe( Layer.provide([
Layer.provide(Account.defaultLayer), runtime,
Layer.provide(Agent.defaultLayer), Account.defaultLayer,
Layer.provide(Auth.defaultLayer), Agent.defaultLayer,
Layer.provide(Command.defaultLayer), Auth.defaultLayer,
Layer.provide(Config.defaultLayer), Command.defaultLayer,
Layer.provide(File.defaultLayer), Config.defaultLayer,
Layer.provide(Format.defaultLayer), File.defaultLayer,
Layer.provide(LSP.defaultLayer), Format.defaultLayer,
Layer.provide(Installation.defaultLayer), LSP.defaultLayer,
Layer.provide(MCP.defaultLayer), Installation.defaultLayer,
Layer.provide(Permission.defaultLayer), MCP.defaultLayer,
Layer.provide(Project.defaultLayer), Permission.defaultLayer,
Layer.provide(ProviderAuth.defaultLayer), Project.defaultLayer,
Layer.provide(Provider.defaultLayer), ProviderAuth.defaultLayer,
Layer.provide(Pty.defaultLayer), Provider.defaultLayer,
Layer.provide(Question.defaultLayer), Pty.defaultLayer,
Layer.provide(Ripgrep.defaultLayer), Question.defaultLayer,
Layer.provide(Session.defaultLayer), Ripgrep.defaultLayer,
) Session.defaultLayer,
.pipe( SessionRunState.defaultLayer,
Layer.provide(SessionRunState.defaultLayer), SessionStatus.defaultLayer,
Layer.provide(SessionStatus.defaultLayer), SessionSummary.defaultLayer,
Layer.provide(SessionSummary.defaultLayer), Skill.defaultLayer,
Layer.provide(Skill.defaultLayer), Todo.defaultLayer,
Layer.provide(Todo.defaultLayer), ToolRegistry.defaultLayer,
Layer.provide(ToolRegistry.defaultLayer), Vcs.defaultLayer,
Layer.provide(Vcs.defaultLayer), Worktree.defaultLayer,
Layer.provide(Worktree.defaultLayer), Bus.layer,
Layer.provide(Bus.layer), HttpServer.layerServices,
Layer.provide(HttpServer.layerServices), ]),
Layer.provideMerge(Observability.layer), Layer.provideMerge(Observability.layer),
) )
export const webHandler = lazy(() => export const webHandler = lazy(() =>
HttpRouter.toWebHandler(routes, { HttpRouter.toWebHandler(routes, {
@@ -1,137 +0,0 @@
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { NonNegativeInt } from "@/util/schema"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/sync"
const ReplayEvent = Schema.Struct({
id: Schema.String,
aggregateID: Schema.String,
seq: NonNegativeInt,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncReplayEvent" })
const ReplayPayload = Schema.Struct({
directory: Schema.String,
events: Schema.NonEmptyArray(ReplayEvent),
}).annotate({ identifier: "SyncReplayInput" })
const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
}).annotate({ identifier: "SyncReplayResponse" })
const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
const HistoryEvent = Schema.Struct({
id: Schema.String,
aggregate_id: Schema.String,
seq: Schema.Number,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncHistoryEvent" })
export const SyncPaths = {
start: `${root}/start`,
replay: `${root}/replay`,
history: `${root}/history`,
} as const
export const SyncApi = HttpApi.make("sync")
.add(
HttpApiGroup.make("sync")
.add(
HttpApiEndpoint.post("start", SyncPaths.start, {
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.start",
summary: "Start workspace sync",
description: "Start sync loops for workspaces in the current project that have active sessions.",
}),
),
HttpApiEndpoint.post("replay", SyncPaths.replay, {
payload: ReplayPayload,
success: ReplayResponse,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.replay",
summary: "Replay sync events",
description: "Validate and replay a complete sync event history.",
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: Schema.Array(HistoryEvent),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.history.list",
summary: "List sync events",
description:
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "sync",
description: "Experimental HttpApi sync routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
Effect.gen(function* () {
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
return true
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
SyncEvent.replayAll(events)
return { sessionID: events[0].aggregateID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
})
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
}),
)
@@ -1,291 +0,0 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../tui"
import { Authorization } from "./auth"
const root = "/tui"
const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" })
const TuiRequestPayload = Schema.Struct({
path: Schema.String,
body: Schema.Unknown,
}).annotate({ identifier: "TuiRequest" })
const TuiPublishPayload = Schema.Union([
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }),
]).annotate({ identifier: "TuiEventInput" })
const commandAliases = {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
} as const
export const TuiPaths = {
appendPrompt: `${root}/append-prompt`,
openHelp: `${root}/open-help`,
openSessions: `${root}/open-sessions`,
openThemes: `${root}/open-themes`,
openModels: `${root}/open-models`,
submitPrompt: `${root}/submit-prompt`,
clearPrompt: `${root}/clear-prompt`,
executeCommand: `${root}/execute-command`,
showToast: `${root}/show-toast`,
publish: `${root}/publish`,
selectSession: `${root}/select-session`,
controlNext: `${root}/control/next`,
controlResponse: `${root}/control/response`,
} as const
export const TuiApi = HttpApi.make("tui")
.add(
HttpApiGroup.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
payload: TuiEvent.PromptAppend.properties,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.appendPrompt",
summary: "Append TUI prompt",
description: "Append prompt to the TUI.",
}),
),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openHelp",
summary: "Open help dialog",
description: "Open the help dialog in the TUI to display user assistance information.",
}),
),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openSessions",
summary: "Open sessions dialog",
description: "Open the session dialog.",
}),
),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openThemes",
summary: "Open themes dialog",
description: "Open the theme dialog.",
}),
),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openModels",
summary: "Open models dialog",
description: "Open the model dialog.",
}),
),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.submitPrompt",
summary: "Submit TUI prompt",
description: "Submit the prompt.",
}),
),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.clearPrompt",
summary: "Clear TUI prompt",
description: "Clear the prompt.",
}),
),
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
payload: CommandPayload,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.executeCommand",
summary: "Execute TUI command",
description: "Execute a TUI command.",
}),
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
payload: TuiEvent.ToastShow.properties,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.showToast",
summary: "Show TUI toast",
description: "Show a toast notification in the TUI.",
}),
),
HttpApiEndpoint.post("publish", TuiPaths.publish, {
payload: TuiPublishPayload,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.publish",
summary: "Publish TUI event",
description: "Publish a TUI event.",
}),
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: Schema.Boolean,
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",
summary: "Select session",
description: "Navigate the TUI to display the specified session.",
}),
),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.next",
summary: "Get next TUI request",
description: "Retrieve the next TUI request from the queue for processing.",
}),
),
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
payload: Schema.Unknown,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.response",
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
yield* publishCommand("help.show")
return true
})
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
yield* publishCommand("session.list")
return true
})
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
yield* publishCommand("session.list")
return true
})
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
yield* publishCommand("model.list")
return true
})
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
yield* publishCommand("prompt.submit")
return true
})
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
yield* publishCommand("prompt.clear")
return true
})
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
return true
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
const row = yield* Effect.sync(() =>
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
),
)
if (!row) return yield* new HttpApiError.NotFound({})
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
return yield* Effect.promise(() => nextTuiRequest())
})
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
submitTuiResponse(ctx.payload)
return true
})
return handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse)
}),
)
@@ -1,166 +0,0 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import { Effect, Schema, Struct } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/experimental/workspace"
const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({
identifier: "WorkspaceCreateInput",
})
const SessionRestorePayload = Schema.Struct(
Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]),
).annotate({
identifier: "WorkspaceSessionRestoreInput",
})
const SessionRestoreResponse = Schema.Struct({
total: Schema.Number,
}).annotate({ identifier: "WorkspaceSessionRestoreResponse" })
export const WorkspacePaths = {
adaptors: `${root}/adaptor`,
list: root,
status: `${root}/status`,
remove: `${root}/:id`,
sessionRestore: `${root}/:id/session-restore`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
.add(
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
success: Schema.Array(WorkspaceAdaptorEntry),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.adaptor.list",
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
success: Schema.Array(Workspace.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.list",
summary: "List workspaces",
description: "List all workspaces.",
}),
),
HttpApiEndpoint.post("create", WorkspacePaths.list, {
payload: CreatePayload,
success: Workspace.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.create",
summary: "Create workspace",
description: "Create a workspace for the current project.",
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
success: Schema.Array(Workspace.ConnectionStatus),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.status",
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
}),
),
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
params: { id: Workspace.Info.fields.id },
success: Schema.UndefinedOr(Workspace.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.remove",
summary: "Remove workspace",
description: "Remove an existing workspace.",
}),
),
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
params: { id: Workspace.Info.fields.id },
payload: SessionRestorePayload,
success: SessionRestoreResponse,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.sessionRestore",
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "workspace",
description: "Experimental HttpApi workspace routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const ctx = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(ctx.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list((yield* InstanceState.context).project)
})
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.create({
...ctx.payload,
projectID: instance.project.id,
}),
),
)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
}),
),
)
})
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
}),
)
+34 -7
View File
@@ -17,6 +17,7 @@ import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware" import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace" import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import * as ServerBackend from "./backend"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@@ -37,13 +38,38 @@ type ServerApp = {
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response> request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
} }
const DefaultHono = lazy(() => createHono({})) const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })))
const DefaultHttpApi = lazy(() => createHttpApi()) const DefaultHttpApi = lazy(() => createDefaultHttpApi())
export const Default = () => (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ? DefaultHttpApi() : DefaultHono())
function select() {
return ServerBackend.select()
}
export const backend = select
export const Default = () => {
const selected = select()
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
}
function create(opts: { cors?: string[] }) { function create(opts: { cors?: string[] }) {
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return createHttpApi() const selected = select()
return createHono(opts) return selected.backend === "effect-httpapi"
? withBackend(selected, createHttpApi())
: withBackend(selected, createHono(opts, selected))
}
export function Legacy(opts: { cors?: string[] } = {}) {
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
}
function createDefaultHttpApi() {
return withBackend(select(), createHttpApi())
}
function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection: ServerBackend.Selection, built: T) {
log.info("server backend selected", ServerBackend.attributes(selection))
return built
} }
function createHttpApi() { function createHttpApi() {
@@ -60,11 +86,12 @@ function createHttpApi() {
} }
} }
function createHono(opts: { cors?: string[] }) { function createHono(opts: { cors?: string[] }, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) {
const backendAttributes = ServerBackend.attributes(selection)
const app = new Hono() const app = new Hono()
.onError(ErrorMiddleware) .onError(ErrorMiddleware)
.use(AuthMiddleware) .use(AuthMiddleware)
.use(LoggerMiddleware) .use(LoggerMiddleware(backendAttributes))
.use(CompressionMiddleware) .use(CompressionMiddleware)
.use(CorsMiddleware(opts)) .use(CorsMiddleware(opts))
.route("/global", GlobalRoutes()) .route("/global", GlobalRoutes())
+14 -9
View File
@@ -21,7 +21,7 @@ const RULES: Array<Rule> = [
{ method: "GET", path: "/session", action: "local" }, { method: "GET", path: "/session", action: "local" },
] ]
function local(method: string, path: string) { export function isLocalWorkspaceRoute(method: string, path: string) {
for (const rule of RULES) { for (const rule of RULES) {
if (rule.method && rule.method !== method) continue if (rule.method && rule.method !== method) continue
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
@@ -30,7 +30,7 @@ function local(method: string, path: string) {
return false return false
} }
function getSessionID(url: URL) { export function getWorkspaceRouteSessionID(url: URL) {
if (url.pathname === "/session/status") return null if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
@@ -39,8 +39,17 @@ function getSessionID(url: URL) {
return SessionID.make(id) return SessionID.make(id)
} }
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
const proxyURL = new URL(target)
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
proxyURL.search = requestURL.search
proxyURL.hash = requestURL.hash
proxyURL.searchParams.delete("workspace")
return proxyURL
}
async function getSessionWorkspace(url: URL) { async function getSessionWorkspace(url: URL) {
const id = getSessionID(url) const id = getWorkspaceRouteSessionID(url)
if (!id) return null if (!id) return null
const session = await AppRuntime.runPromise( const session = await AppRuntime.runPromise(
@@ -73,7 +82,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}) })
} }
if (local(c.req.method, url.pathname)) { if (isLocalWorkspaceRoute(c.req.method, url.pathname)) {
// No instance provided because we are serving cached data; there // No instance provided because we are serving cached data; there
// is no instance to work with // is no instance to work with
return next() return next()
@@ -96,11 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
}) })
} }
const proxyURL = new URL(target.url) const proxyURL = workspaceProxyURL(target.url, url)
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}`
proxyURL.search = url.search
proxyURL.hash = url.hash
proxyURL.searchParams.delete("workspace")
log.info("workspace proxy forwarding", { log.info("workspace proxy forwarding", {
workspaceID, workspaceID,
+56 -48
View File
@@ -42,7 +42,7 @@ export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}
export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = namedSchemaError("StructuredOutputError", { export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
message: Schema.String, message: Schema.String,
retries: Schema.Number, retries: NonNegativeInt,
}) })
export const AuthError = namedSchemaError("ProviderAuthError", { export const AuthError = namedSchemaError("ProviderAuthError", {
providerID: Schema.String, providerID: Schema.String,
@@ -50,7 +50,7 @@ export const AuthError = namedSchemaError("ProviderAuthError", {
}) })
export const APIError = namedSchemaError("APIError", { export const APIError = namedSchemaError("APIError", {
message: Schema.String, message: Schema.String,
statusCode: Schema.optional(Schema.Number), statusCode: Schema.optional(NonNegativeInt),
isRetryable: Schema.Boolean, isRetryable: Schema.Boolean,
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
responseBody: Schema.optional(Schema.String), responseBody: Schema.optional(Schema.String),
@@ -116,8 +116,8 @@ export const TextPart = Schema.Struct({
ignored: Schema.optional(Schema.Boolean), ignored: Schema.optional(Schema.Boolean),
time: Schema.optional( time: Schema.optional(
Schema.Struct({ Schema.Struct({
start: Schema.Number, start: NonNegativeInt,
end: Schema.optional(Schema.Number), end: Schema.optional(NonNegativeInt),
}), }),
), ),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
@@ -132,8 +132,8 @@ export const ReasoningPart = Schema.Struct({
text: Schema.String, text: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({ time: Schema.Struct({
start: Schema.Number, start: NonNegativeInt,
end: Schema.optional(Schema.Number), end: Schema.optional(NonNegativeInt),
}), }),
}) })
.annotate({ identifier: "ReasoningPart" }) .annotate({ identifier: "ReasoningPart" })
@@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
const filePartSourceBase = { const filePartSourceBase = {
text: Schema.Struct({ text: Schema.Struct({
value: Schema.String, value: Schema.String,
start: Schema.Int, start: NonNegativeInt,
end: Schema.Int, end: NonNegativeInt,
}).annotate({ identifier: "FilePartSourceText" }), }).annotate({ identifier: "FilePartSourceText" }),
} }
@@ -162,7 +162,7 @@ export const SymbolSource = Schema.Struct({
path: Schema.String, path: Schema.String,
range: LSP.Range, range: LSP.Range,
name: Schema.String, name: Schema.String,
kind: Schema.Int, kind: NonNegativeInt,
}) })
.annotate({ identifier: "SymbolSource" }) .annotate({ identifier: "SymbolSource" })
.pipe(withStatics((s) => ({ zod: zod(s) }))) .pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -201,8 +201,8 @@ export const AgentPart = Schema.Struct({
source: Schema.optional( source: Schema.optional(
Schema.Struct({ Schema.Struct({
value: Schema.String, value: Schema.String,
start: Schema.Int, start: NonNegativeInt,
end: Schema.Int, end: NonNegativeInt,
}), }),
), ),
}) })
@@ -242,11 +242,10 @@ export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPar
export const RetryPart = Schema.Struct({ export const RetryPart = Schema.Struct({
...partBase, ...partBase,
type: Schema.Literal("retry"), type: Schema.Literal("retry"),
attempt: Schema.Number, attempt: NonNegativeInt,
// APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate. error: APIError.EffectSchema,
error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }),
time: Schema.Struct({ time: Schema.Struct({
created: Schema.Number, created: NonNegativeInt,
}), }),
}) })
.annotate({ identifier: "RetryPart" }) .annotate({ identifier: "RetryPart" })
@@ -269,15 +268,15 @@ export const StepFinishPart = Schema.Struct({
type: Schema.Literal("step-finish"), type: Schema.Literal("step-finish"),
reason: Schema.String, reason: Schema.String,
snapshot: Schema.optional(Schema.String), snapshot: Schema.optional(Schema.String),
cost: Schema.Number, cost: Schema.Finite,
tokens: Schema.Struct({ tokens: Schema.Struct({
total: Schema.optional(Schema.Number), total: Schema.optional(NonNegativeInt),
input: Schema.Number, input: NonNegativeInt,
output: Schema.Number, output: NonNegativeInt,
reasoning: Schema.Number, reasoning: NonNegativeInt,
cache: Schema.Struct({ cache: Schema.Struct({
read: Schema.Number, read: NonNegativeInt,
write: Schema.Number, write: NonNegativeInt,
}), }),
}), }),
}) })
@@ -300,7 +299,7 @@ export const ToolStateRunning = Schema.Struct({
title: Schema.optional(Schema.String), title: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({ time: Schema.Struct({
start: Schema.Number, start: NonNegativeInt,
}), }),
}) })
.annotate({ identifier: "ToolStateRunning" }) .annotate({ identifier: "ToolStateRunning" })
@@ -314,9 +313,9 @@ export const ToolStateCompleted = Schema.Struct({
title: Schema.String, title: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Any), metadata: Schema.Record(Schema.String, Schema.Any),
time: Schema.Struct({ time: Schema.Struct({
start: Schema.Number, start: NonNegativeInt,
end: Schema.Number, end: NonNegativeInt,
compacted: Schema.optional(Schema.Number), compacted: Schema.optional(NonNegativeInt),
}), }),
attachments: Schema.optional(Schema.Array(FilePart)), attachments: Schema.optional(Schema.Array(FilePart)),
}) })
@@ -336,8 +335,8 @@ export const ToolStateError = Schema.Struct({
error: Schema.String, error: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({ time: Schema.Struct({
start: Schema.Number, start: NonNegativeInt,
end: Schema.Number, end: NonNegativeInt,
}), }),
}) })
.annotate({ identifier: "ToolStateError" }) .annotate({ identifier: "ToolStateError" })
@@ -380,7 +379,7 @@ export const User = Schema.Struct({
...messageBase, ...messageBase,
role: Schema.Literal("user"), role: Schema.Literal("user"),
time: Schema.Struct({ time: Schema.Struct({
created: Schema.Number, created: NonNegativeInt,
}), }),
format: Schema.optional(_Format), format: Schema.optional(_Format),
summary: Schema.optional( summary: Schema.optional(
@@ -447,9 +446,7 @@ export type Part =
| RetryPart | RetryPart
| CompactionPart | CompactionPart
// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived // Zod discriminated union kept for the legacy Hono OpenAPI path.
// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the
// error classes to Schema.TaggedErrorClass is a separate slice.
const AssistantErrorZod = z.discriminatedUnion("name", [ const AssistantErrorZod = z.discriminatedUnion("name", [
AuthError.Schema, AuthError.Schema,
NamedError.Unknown.Schema, NamedError.Unknown.Schema,
@@ -461,6 +458,17 @@ const AssistantErrorZod = z.discriminatedUnion("name", [
]) ])
type AssistantError = z.infer<typeof AssistantErrorZod> type AssistantError = z.infer<typeof AssistantErrorZod>
// Effect Schema for the same union — used by HttpApi OpenAPI generation.
const AssistantErrorSchema = Schema.Union([
AuthError.EffectSchema,
Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ identifier: "UnknownError" }),
OutputLengthError.EffectSchema,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
APIError.EffectSchema,
]).annotate({ discriminator: "name" })
// ── Prompt input schemas ───────────────────────────────────────────────────── // ── Prompt input schemas ─────────────────────────────────────────────────────
// //
// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the // Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
@@ -477,8 +485,8 @@ export const TextPartInput = Schema.Struct({
ignored: Schema.optional(Schema.Boolean), ignored: Schema.optional(Schema.Boolean),
time: Schema.optional( time: Schema.optional(
Schema.Struct({ Schema.Struct({
start: Schema.Number, start: NonNegativeInt,
end: Schema.optional(Schema.Number), end: Schema.optional(NonNegativeInt),
}), }),
), ),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
@@ -506,8 +514,8 @@ export const AgentPartInput = Schema.Struct({
source: Schema.optional( source: Schema.optional(
Schema.Struct({ Schema.Struct({
value: Schema.String, value: Schema.String,
start: Schema.Int, start: NonNegativeInt,
end: Schema.Int, end: NonNegativeInt,
}), }),
), ),
}) })
@@ -537,10 +545,10 @@ export const Assistant = Schema.Struct({
...messageBase, ...messageBase,
role: Schema.Literal("assistant"), role: Schema.Literal("assistant"),
time: Schema.Struct({ time: Schema.Struct({
created: Schema.Number, created: NonNegativeInt,
completed: Schema.optional(Schema.Number), completed: Schema.optional(NonNegativeInt),
}), }),
error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })), error: Schema.optional(AssistantErrorSchema),
parentID: MessageID, parentID: MessageID,
modelID: ModelID, modelID: ModelID,
providerID: ProviderID, providerID: ProviderID,
@@ -554,15 +562,15 @@ export const Assistant = Schema.Struct({
root: Schema.String, root: Schema.String,
}), }),
summary: Schema.optional(Schema.Boolean), summary: Schema.optional(Schema.Boolean),
cost: Schema.Number, cost: Schema.Finite,
tokens: Schema.Struct({ tokens: Schema.Struct({
total: Schema.optional(Schema.Number), total: Schema.optional(NonNegativeInt),
input: Schema.Number, input: NonNegativeInt,
output: Schema.Number, output: NonNegativeInt,
reasoning: Schema.Number, reasoning: NonNegativeInt,
cache: Schema.Struct({ cache: Schema.Struct({
read: Schema.Number, read: NonNegativeInt,
write: Schema.Number, write: NonNegativeInt,
}), }),
}), }),
structured: Schema.optional(Schema.Any), structured: Schema.optional(Schema.Any),
@@ -594,7 +602,7 @@ const RemovedEventSchema = Schema.Struct({
const PartUpdatedEventSchema = Schema.Struct({ const PartUpdatedEventSchema = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
part: _Part, part: _Part,
time: Schema.Number, time: NonNegativeInt,
}) })
const PartRemovedEventSchema = Schema.Struct({ const PartRemovedEventSchema = Schema.Struct({
@@ -651,7 +659,7 @@ export type WithParts = {
const Cursor = Schema.Struct({ const Cursor = Schema.Struct({
id: MessageID, id: MessageID,
time: Schema.Number, time: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)),
}) })
type Cursor = typeof Cursor.Type type Cursor = typeof Cursor.Type
+14 -14
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { SessionID } from "./schema" import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
import { namedSchemaError } from "@/util/named-schema-error" import { namedSchemaError } from "@/util/named-schema-error"
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
@@ -33,7 +33,7 @@ const UnknownErrorEffect = Schema.Struct({
export const ToolCall = Schema.Struct({ export const ToolCall = Schema.Struct({
state: Schema.Literal("call"), state: Schema.Literal("call"),
step: Schema.optional(Schema.Number), step: Schema.optional(NonNegativeInt),
toolCallId: Schema.String, toolCallId: Schema.String,
toolName: Schema.String, toolName: Schema.String,
args: Schema.Unknown, args: Schema.Unknown,
@@ -44,7 +44,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolPartialCall = Schema.Struct({ export const ToolPartialCall = Schema.Struct({
state: Schema.Literal("partial-call"), state: Schema.Literal("partial-call"),
step: Schema.optional(Schema.Number), step: Schema.optional(NonNegativeInt),
toolCallId: Schema.String, toolCallId: Schema.String,
toolName: Schema.String, toolName: Schema.String,
args: Schema.Unknown, args: Schema.Unknown,
@@ -55,7 +55,7 @@ export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
export const ToolResult = Schema.Struct({ export const ToolResult = Schema.Struct({
state: Schema.Literal("result"), state: Schema.Literal("result"),
step: Schema.optional(Schema.Number), step: Schema.optional(NonNegativeInt),
toolCallId: Schema.String, toolCallId: Schema.String,
toolName: Schema.String, toolName: Schema.String,
args: Schema.Unknown, args: Schema.Unknown,
@@ -141,8 +141,8 @@ export const Info = Schema.Struct({
parts: Schema.Array(MessagePart), parts: Schema.Array(MessagePart),
metadata: Schema.Struct({ metadata: Schema.Struct({
time: Schema.Struct({ time: Schema.Struct({
created: Schema.Number, created: NonNegativeInt,
completed: Schema.optional(Schema.Number), completed: Schema.optional(NonNegativeInt),
}), }),
error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])), error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
sessionID: SessionID, sessionID: SessionID,
@@ -153,8 +153,8 @@ export const Info = Schema.Struct({
title: Schema.String, title: Schema.String,
snapshot: Schema.optional(Schema.String), snapshot: Schema.optional(Schema.String),
time: Schema.Struct({ time: Schema.Struct({
start: Schema.Number, start: NonNegativeInt,
end: Schema.Number, end: NonNegativeInt,
}), }),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
@@ -169,15 +169,15 @@ export const Info = Schema.Struct({
cwd: Schema.String, cwd: Schema.String,
root: Schema.String, root: Schema.String,
}), }),
cost: Schema.Number, cost: Schema.Finite,
summary: Schema.optional(Schema.Boolean), summary: Schema.optional(Schema.Boolean),
tokens: Schema.Struct({ tokens: Schema.Struct({
input: Schema.Number, input: NonNegativeInt,
output: Schema.Number, output: NonNegativeInt,
reasoning: Schema.Number, reasoning: NonNegativeInt,
cache: Schema.Struct({ cache: Schema.Struct({
read: Schema.Number, read: NonNegativeInt,
write: Schema.Number, write: NonNegativeInt,
}), }),
}), }),
}), }),
+14 -14
View File
@@ -38,7 +38,7 @@ import { Permission } from "@/permission"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { Effect, Layer, Option, Context, Schema, Types } from "effect"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { optionalOmitUndefined, withStatics } from "@/util/schema" import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -132,9 +132,9 @@ function sessionPath(worktree: string, cwd: string) {
} }
const Summary = Schema.Struct({ const Summary = Schema.Struct({
additions: Schema.Number, additions: NonNegativeInt,
deletions: Schema.Number, deletions: NonNegativeInt,
files: Schema.Number, files: NonNegativeInt,
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
}) })
@@ -143,10 +143,10 @@ const Share = Schema.Struct({
}) })
const Time = Schema.Struct({ const Time = Schema.Struct({
created: Schema.Number, created: NonNegativeInt,
updated: Schema.Number, updated: NonNegativeInt,
compacting: optionalOmitUndefined(Schema.Number), compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Number), archived: optionalOmitUndefined(NonNegativeInt),
}) })
const Revert = Schema.Struct({ const Revert = Schema.Struct({
@@ -215,7 +215,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
) )
export const SetArchivedInput = Schema.Struct({ export const SetArchivedInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
time: Schema.optional(Schema.Number), time: Schema.optional(NonNegativeInt),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export const SetPermissionInput = Schema.Struct({ export const SetPermissionInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
@@ -228,7 +228,7 @@ export const SetRevertInput = Schema.Struct({
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export const MessagesInput = Schema.Struct({ export const MessagesInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
limit: Schema.optional(Schema.Number), limit: Schema.optional(NonNegativeInt),
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
const CreatedEventSchema = Schema.Struct({ const CreatedEventSchema = Schema.Struct({
@@ -241,10 +241,10 @@ const UpdatedShare = Schema.Struct({
}) })
const UpdatedTime = Schema.Struct({ const UpdatedTime = Schema.Struct({
created: Schema.optional(Schema.NullOr(Schema.Number)), created: Schema.optional(Schema.NullOr(NonNegativeInt)),
updated: Schema.optional(Schema.NullOr(Schema.Number)), updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
compacting: Schema.optional(Schema.NullOr(Schema.Number)), compacting: Schema.optional(Schema.NullOr(NonNegativeInt)),
archived: Schema.optional(Schema.NullOr(Schema.Number)), archived: Schema.optional(Schema.NullOr(NonNegativeInt)),
}) })
const UpdatedInfo = Schema.Struct({ const UpdatedInfo = Schema.Struct({
+3 -3
View File
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { SessionID } from "./schema" import { SessionID } from "./schema"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
import { Effect, Layer, Context, Schema } from "effect" import { Effect, Layer, Context, Schema } from "effect"
import z from "zod" import z from "zod"
@@ -13,9 +13,9 @@ export const Info = Schema.Union([
}), }),
Schema.Struct({ Schema.Struct({
type: Schema.Literal("retry"), type: Schema.Literal("retry"),
attempt: Schema.Number, attempt: NonNegativeInt,
message: Schema.String, message: Schema.String,
next: Schema.Number, next: NonNegativeInt,
}), }),
Schema.Struct({ Schema.Struct({
type: Schema.Literal("busy"), type: Schema.Literal("busy"),
+3 -3
View File
@@ -10,7 +10,7 @@ import { Hash } from "@opencode-ai/core/util/hash"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
export const Patch = Schema.Struct({ export const Patch = Schema.Struct({
@@ -22,8 +22,8 @@ export type Patch = typeof Patch.Type
export const FileDiff = Schema.Struct({ export const FileDiff = Schema.Struct({
file: Schema.String, file: Schema.String,
patch: Schema.String, patch: Schema.String,
additions: Schema.Number, additions: NonNegativeInt,
deletions: Schema.Number, deletions: NonNegativeInt,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
}) })
.annotate({ identifier: "SnapshotFileDiff" }) .annotate({ identifier: "SnapshotFileDiff" })
+3 -2
View File
@@ -5,6 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
import z from "zod" import z from "zod"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
import { NonNegativeInt } from "@/util/schema"
import { Git } from "@/git" import { Git } from "@/git"
const log = Log.create({ service: "storage" }) const log = Log.create({ service: "storage" })
@@ -41,8 +42,8 @@ const MessageFile = Schema.Struct({
}) })
const DiffFile = Schema.Struct({ const DiffFile = Schema.Struct({
additions: Schema.Number, additions: NonNegativeInt,
deletions: Schema.Number, deletions: NonNegativeInt,
}) })
const SummaryFile = Schema.Struct({ const SummaryFile = Schema.Struct({
+16
View File
@@ -294,4 +294,20 @@ export function payloads() {
.toArray() .toArray()
} }
export function effectPayloads() {
return registry
.entries()
.map(([type, def]) =>
EffectSchema.Struct({
type: EffectSchema.Literal("sync"),
name: EffectSchema.Literal(type),
id: EffectSchema.String,
seq: EffectSchema.Finite,
aggregateID: EffectSchema.Literal(def.aggregate),
data: def.schema,
}).annotate({ identifier: `SyncEvent.${type}` }),
)
.toArray()
}
export * as SyncEvent from "." export * as SyncEvent from "."
+2 -1
View File
@@ -1,4 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { PositiveInt } from "@/util/schema"
import os from "os" import os from "os"
import { createWriteStream } from "node:fs" import { createWriteStream } from "node:fs"
import * as Tool from "./tool" import * as Tool from "./tool"
@@ -53,7 +54,7 @@ const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurs
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
command: Schema.String.annotate({ description: "The command to execute" }), command: Schema.String.annotate({ description: "The command to execute" }),
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }),
workdir: Schema.optional(Schema.String).annotate({ workdir: Schema.optional(Schema.String).annotate({
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
}), }),
+1 -1
View File
@@ -9,7 +9,7 @@ export const Parameters = Schema.Struct({
description: description:
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
}), }),
tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) tokensNum: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000))
.check(Schema.isLessThanOrEqualTo(50000)) .check(Schema.isLessThanOrEqualTo(50000))
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
.annotate({ .annotate({
+6 -6
View File
@@ -23,12 +23,12 @@ const operations = [
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }), operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }), filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
line: Schema.Number.check(Schema.isInt()) line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({
.check(Schema.isGreaterThanOrEqualTo(1)) description: "The line number (1-based, as shown in editors)",
.annotate({ description: "The line number (1-based, as shown in editors)" }), }),
character: Schema.Number.check(Schema.isInt()) character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({
.check(Schema.isGreaterThanOrEqualTo(1)) description: "The character offset (1-based, as shown in editors)",
.annotate({ description: "The character offset (1-based, as shown in editors)" }), }),
query: Schema.optional(Schema.String).annotate({ query: Schema.optional(Schema.String).annotate({
description: "Search query for workspaceSymbol. Empty string requests all symbols.", description: "Search query for workspaceSymbol. Empty string requests all symbols.",
}), }),
+3 -2
View File
@@ -1,4 +1,5 @@
import { Effect, Option, Schema, Scope } from "effect" import { Effect, Option, Schema, Scope } from "effect"
import { NonNegativeInt } from "@/util/schema"
import { createReadStream } from "fs" import { createReadStream } from "fs"
import * as path from "path" import * as path from "path"
import { createInterface } from "readline" import { createInterface } from "readline"
@@ -25,10 +26,10 @@ const SAMPLE_BYTES = 4096
// unchanged; purely CLI-facing uses must now send numbers rather than strings. // unchanged; purely CLI-facing uses must now send numbers rather than strings.
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }),
offset: Schema.optional(Schema.Number).annotate({ offset: Schema.optional(NonNegativeInt).annotate({
description: "The line number to start reading from (1-indexed)", description: "The line number to start reading from (1-indexed)",
}), }),
limit: Schema.optional(Schema.Number).annotate({ limit: Schema.optional(NonNegativeInt).annotate({
description: "The maximum number of lines to read (defaults to 2000)", description: "The maximum number of lines to read (defaults to 2000)",
}), }),
}) })
@@ -26,10 +26,17 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
}) })
.meta({ ref: tag }) .meta({ ref: tag })
// Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
const effectSchema = Schema.Struct({
name: Schema.Literal(tag),
data: dataSchema,
}).annotate({ identifier: tag })
type Data = Schema.Schema.Type<typeof dataSchema> type Data = Schema.Schema.Type<typeof dataSchema>
class NamedSchemaError extends Error { class NamedSchemaError extends Error {
static readonly Schema = wire static readonly Schema = wire
static readonly EffectSchema = effectSchema
static readonly tag = tag static readonly tag = tag
public static isInstance(input: unknown): input is NamedSchemaError { public static isInstance(input: unknown): input is NamedSchemaError {
return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag
+2
View File
@@ -11,6 +11,8 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
*/ */
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
/** /**
* Optional public JSON field that can hold explicit `undefined` on the type * Optional public JSON field that can hold explicit `undefined` on the type
* side but encodes it as an omitted key, matching legacy `JSON.stringify`. * side but encodes it as an omitted key, matching legacy `JSON.stringify`.
+8 -7
View File
@@ -1,4 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { NonNegativeInt } from "@/util/schema"
import { SessionEvent } from "./session-event" import { SessionEvent } from "./session-event"
export const ID = SessionEvent.ID export const ID = SessionEvent.ID
@@ -105,7 +106,7 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
}) {} }) {}
export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({ export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
attempt: Schema.Number, attempt: NonNegativeInt,
error: SessionEvent.RetryError, error: SessionEvent.RetryError,
time: Schema.Struct({ time: Schema.Struct({
created: Schema.DateTimeUtc, created: Schema.DateTimeUtc,
@@ -132,14 +133,14 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
type: Schema.Literal("assistant"), type: Schema.Literal("assistant"),
content: AssistantContent.pipe(Schema.Array), content: AssistantContent.pipe(Schema.Array),
retries: AssistantRetry.pipe(Schema.Array, Schema.optional), retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
cost: Schema.Number.pipe(Schema.optional), cost: Schema.Finite.pipe(Schema.optional),
tokens: Schema.Struct({ tokens: Schema.Struct({
input: Schema.Number, input: NonNegativeInt,
output: Schema.Number, output: NonNegativeInt,
reasoning: Schema.Number, reasoning: NonNegativeInt,
cache: Schema.Struct({ cache: Schema.Struct({
read: Schema.Number, read: NonNegativeInt,
write: Schema.Number, write: NonNegativeInt,
}), }),
}).pipe(Schema.optional), }).pipe(Schema.optional),
error: Schema.String.pipe(Schema.optional), error: Schema.String.pipe(Schema.optional),
+11 -11
View File
@@ -1,5 +1,5 @@
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema" import { NonNegativeInt, withStatics } from "@/util/schema"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import { Schema } from "effect" import { Schema } from "effect"
@@ -25,8 +25,8 @@ export namespace SessionEvent {
} }
export class Source extends Schema.Class<Source>("Session.Event.Source")({ export class Source extends Schema.Class<Source>("Session.Event.Source")({
start: Schema.Number, start: NonNegativeInt,
end: Schema.Number, end: NonNegativeInt,
text: Schema.String, text: Schema.String,
}) {} }) {}
@@ -55,7 +55,7 @@ export namespace SessionEvent {
export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({ export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
message: Schema.String, message: Schema.String,
statusCode: Schema.Number.pipe(Schema.optional), statusCode: NonNegativeInt.pipe(Schema.optional),
isRetryable: Schema.Boolean, isRetryable: Schema.Boolean,
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
responseBody: Schema.String.pipe(Schema.optional), responseBody: Schema.String.pipe(Schema.optional),
@@ -123,14 +123,14 @@ export namespace SessionEvent {
...Base, ...Base,
type: Schema.Literal("step.ended"), type: Schema.Literal("step.ended"),
reason: Schema.String, reason: Schema.String,
cost: Schema.Number, cost: Schema.Finite,
tokens: Schema.Struct({ tokens: Schema.Struct({
input: Schema.Number, input: NonNegativeInt,
output: Schema.Number, output: NonNegativeInt,
reasoning: Schema.Number, reasoning: NonNegativeInt,
cache: Schema.Struct({ cache: Schema.Struct({
read: Schema.Number, read: NonNegativeInt,
write: Schema.Number, write: NonNegativeInt,
}), }),
}), }),
}) { }) {
@@ -395,7 +395,7 @@ export namespace SessionEvent {
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({ export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
...Base, ...Base,
type: Schema.Literal("retried"), type: Schema.Literal("retried"),
attempt: Schema.Number, attempt: NonNegativeInt,
error: RetryError, error: RetryError,
}) { }) {
static create(input: BaseInput & { attempt: number; error: RetryError }) { static create(input: BaseInput & { attempt: number; error: RetryError }) {
@@ -1,9 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/control" import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file" import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global" import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public" import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -57,16 +57,32 @@ function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof
) )
} }
function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) { function openApiRequestBodies(spec: OpenApiSpec) {
return Object.fromEntries( return Object.fromEntries(
Object.entries(spec.paths).flatMap(([path, item]) => Object.entries(spec.paths).flatMap(([path, item]) =>
methods methods
.filter((method) => item[method]) .filter((method) => item[method])
.map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(item[method]?.requestBody)]), .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]),
), ),
) )
} }
type OpenApiSpec = {
components?: {
schemas?: Record<string, unknown>
}
paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
}
type OpenApiSchema = {
$ref?: string
allOf?: unknown[]
anyOf?: unknown[]
oneOf?: unknown[]
properties?: Record<string, unknown>
type?: string | string[]
}
type Operation = { type Operation = {
parameters?: unknown[] parameters?: unknown[]
responses?: unknown responses?: unknown
@@ -74,7 +90,7 @@ type Operation = {
} }
type RequestBody = { type RequestBody = {
content?: Record<string, { schema?: { $ref?: string; type?: string } }> content?: Record<string, { schema?: OpenApiSchema }>
required?: boolean required?: boolean
} }
@@ -97,17 +113,27 @@ function parameterSchema(input: {
return param.schema return param.schema
} }
function requestBodyKey(body: unknown) { function requestBodyKey(spec: OpenApiSpec, body: unknown) {
if (!body || typeof body !== "object" || !("content" in body)) return "" if (!body || typeof body !== "object" || !("content" in body)) return ""
const requestBody = body as RequestBody const requestBody = body as RequestBody
return JSON.stringify({ return JSON.stringify({
required: requestBody.required === true, required: requestBody.required === true,
content: Object.entries(requestBody.content ?? {}) content: Object.entries(requestBody.content ?? {})
.map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"]) .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
.sort(), .sort(),
}) })
} }
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
if (!schema) return ""
const resolved = (schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema) as
| OpenApiSchema
| undefined
if (resolved?.properties) return "object"
if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object"
return resolved?.type ?? schema.type ?? "inline"
}
function responseContentTypes(input: { function responseContentTypes(input: {
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> } spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
path: string path: string
@@ -146,6 +172,14 @@ afterEach(async () => {
}) })
describe("HttpApi server", () => { describe("HttpApi server", () => {
test("keeps Effect HttpApi behind the feature flag", () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" })
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" })
})
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
const honoRoutes = openApiRouteKeys(await Server.openapi()) const honoRoutes = openApiRouteKeys(await Server.openapi())
const effectRoutes = openApiRouteKeys(effectOpenApi()) const effectRoutes = openApiRouteKeys(effectOpenApi())
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { Context } from "effect" import { Context } from "effect"
import path from "path" import path from "path"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/file" import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -4,8 +4,8 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -19,7 +19,7 @@ const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
function app(experimental: boolean) { function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return Server.Default().app return experimental ? Server.Default().app : Server.Legacy().app
} }
type TestApp = ReturnType<typeof app> type TestApp = ReturnType<typeof app>
@@ -3,7 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -19,7 +19,7 @@ const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
function app(experimental: boolean) { function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return Server.Default().app return experimental ? Server.Default().app : Server.Legacy().app
} }
type TestApp = ReturnType<typeof app> type TestApp = ReturnType<typeof app>
@@ -19,7 +19,7 @@ const oauthInstructions = "Finish OAuth"
function app(experimental: boolean) { function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return Server.Default().app return experimental ? Server.Default().app : Server.Legacy().app
} }
function requestAuthorize(input: { function requestAuthorize(input: {
@@ -3,7 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { PtyID } from "../../src/pty/schema" import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
+556 -10
View File
@@ -1,40 +1,209 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session as SessionNs } from "@/session/session"
import { TestLLMServer } from "../lib/llm-server"
import path from "path" import path from "path"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
type Backend = "legacy" | "httpapi"
type Sdk = ReturnType<typeof createOpencodeClient>
type SdkResult = { response: Response; data?: unknown; error?: unknown }
function client(directory?: string) { function app(backend: Backend, input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
const handler = ExperimentalHttpApiServer.webHandler().handler Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
}
function client(
backend: Backend,
directory?: string,
input?: { password?: string; username?: string; headers?: Record<string, string> },
) {
const serverApp = app(backend, input)
const fetch = Object.assign( const fetch = Object.assign(
(request: RequestInfo | URL, init?: RequestInit) => async (request: RequestInfo | URL, init?: RequestInit) =>
handler(new Request(request, init), ExperimentalHttpApiServer.context), await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
{ preconnect: globalThis.fetch.preconnect }, { preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch ) satisfies typeof globalThis.fetch
return createOpencodeClient({ return createOpencodeClient({
baseUrl: "http://localhost", baseUrl: "http://localhost",
directory, directory,
headers: input?.headers,
fetch, fetch,
}) })
} }
function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
function providerConfig(url: string) {
return {
formatter: false,
lsp: false,
provider: {
test: {
name: "Test",
id: "test",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
"test-model": {
id: "test-model",
name: "Test Model",
attachment: false,
reasoning: false,
temperature: false,
tool_call: true,
release_date: "2025-01-01",
limit: { context: 100000, output: 10000 },
cost: { input: 0, output: 0 },
options: {},
},
},
options: {
apiKey: "test-key",
baseURL: url,
},
},
},
}
}
async function expectStatus(result: Promise<{ response: Response }>, status: number) { async function expectStatus(result: Promise<{ response: Response }>, status: number) {
expect((await result).response.status).toBe(status) expect((await result).response.status).toBe(status)
} }
async function capture(result: Promise<SdkResult>) {
const response = await result
return {
status: response.response.status,
data: response.data,
error: response.error,
}
}
function record(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
function array(value: unknown) {
return Array.isArray(value) ? value : []
}
function statuses(input: Record<string, Awaited<ReturnType<typeof capture>>>) {
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
}
function firstPartText(value: unknown) {
return record(array(record(value).parts)[0]).text
}
function sessionTitles(value: unknown) {
return array(value)
.map((item) => record(item).title)
.filter((title): title is string => typeof title === "string")
.sort()
}
async function runSession<A, E>(directory: string, effect: Effect.Effect<A, E, SessionNs.Service>) {
return Instance.provide({
directory,
fn: () => Effect.runPromise(effect.pipe(Effect.provide(SessionNs.defaultLayer))),
})
}
async function seedMessage(directory: string, sessionID: string) {
const id = SessionID.make(sessionID)
return runSession(
directory,
SessionNs.Service.use((svc) =>
Effect.gen(function* () {
const message = yield* svc.updateMessage({
id: MessageID.ascending(),
sessionID: id,
role: "user",
time: { created: Date.now() },
agent: "test",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
tools: {},
mode: "",
} as unknown as MessageV2.Info)
const part = yield* svc.updatePart({
id: PartID.ascending(),
sessionID: id,
messageID: message.id,
type: "text",
text: "seeded message",
})
return { message, part }
}),
),
)
}
async function compareBackends<T>(scenario: (backend: Backend) => Promise<T>) {
const legacy = await scenario("legacy")
await Instance.disposeAll()
await resetDatabase()
const httpapi = await scenario("httpapi")
expect(httpapi).toEqual(legacy)
}
async function withTmp<T>(backend: Backend, fn: (input: { sdk: Sdk; directory: string }) => Promise<T>) {
await using tmp = await tmpdir({
git: true,
config: { formatter: false, lsp: false },
init: async (dir) => {
await Bun.write(path.join(dir, "hello.txt"), "hello")
await Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")
},
})
return fn({ sdk: client(backend, tmp.path), directory: tmp.path })
}
async function withFakeLlm<T>(
backend: Backend,
fn: (input: { sdk: Sdk; directory: string; llm: TestLLMServer["Service"] }) => Promise<T>,
) {
return Effect.runPromise(
Effect.gen(function* () {
const llm = yield* TestLLMServer
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true, config: providerConfig(llm.url) })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
return yield* Effect.promise(() => fn({ sdk: client(backend, tmp.path), directory: tmp.path, llm }))
}).pipe(Effect.scoped, Effect.provide(TestLLMServer.layer)),
)
}
afterEach(async () => { afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
await Instance.disposeAll()
await resetDatabase() await resetDatabase()
}) })
describe("HttpApi SDK", () => { describe("HttpApi SDK", () => {
test("uses the generated SDK for global and control routes", async () => { test("uses the generated SDK for global and control routes", async () => {
const sdk = client() const sdk = client("httpapi")
const health = await sdk.global.health() const health = await sdk.global.health()
expect(health.response.status).toBe(200) expect(health.response.status).toBe(200)
@@ -60,7 +229,7 @@ describe("HttpApi SDK", () => {
config: { formatter: false, lsp: false }, config: { formatter: false, lsp: false },
init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"), init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"),
}) })
const sdk = client(tmp.path) const sdk = client("httpapi", tmp.path)
const file = await sdk.file.read({ path: "hello.txt" }) const file = await sdk.file.read({ path: "hello.txt" })
expect(file.response.status).toBe(200) expect(file.response.status).toBe(200)
@@ -81,4 +250,381 @@ describe("HttpApi SDK", () => {
expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200), expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200),
]) ])
}) })
test("matches generated SDK global and control behavior across backends", async () => {
await compareBackends(async (backend) => {
const sdk = client(backend)
const health = await capture(sdk.global.health())
const log = await capture(sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
const invalidAuth = await capture(sdk.auth.set({ providerID: "test" }))
return {
statuses: statuses({ health, log, invalidAuth }),
health: record(health.data).healthy,
log: log.data,
}
})
})
test("matches generated SDK global event stream across backends", async () => {
await compareBackends(async (backend) => {
const events = await client(backend).global.event({ signal: AbortSignal.timeout(1_000) })
try {
const first = await events.stream.next()
return {
type: record(record(first.value).payload).type,
}
} finally {
await events.stream.return(undefined)
}
})
})
test("matches generated SDK instance event stream across backends", async () => {
await compareBackends((backend) =>
withTmp(backend, async ({ sdk }) => {
const events = await sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })
try {
const first = await events.stream.next()
return {
type: record(record(first.value).payload).type,
}
} finally {
await events.stream.return(undefined)
}
}),
)
})
test("matches generated SDK basic auth behavior across backends", async () => {
await compareBackends((backend) =>
withTmp(backend, async ({ directory }) => {
const missing = await capture(
client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }),
)
const bad = await capture(
client(backend, directory, {
password: "secret",
headers: { authorization: authorization("opencode", "wrong") },
}).file.read({ path: "hello.txt" }),
)
const good = await capture(
client(backend, directory, {
password: "secret",
headers: { authorization: authorization("opencode", "secret") },
}).file.read({ path: "hello.txt" }),
)
return {
statuses: statuses({ missing, bad, good }),
content: record(good.data).content,
}
}),
)
})
test("matches generated SDK instance read routes across backends", async () => {
await compareBackends((backend) =>
withTmp(backend, async ({ sdk, directory }) => {
const project = await capture(sdk.project.current())
const projects = await capture(sdk.project.list())
const paths = await capture(sdk.path.get())
const config = await capture(sdk.config.get())
const providers = await capture(sdk.config.providers())
const file = await capture(sdk.file.read({ path: "hello.txt" }))
const files = await capture(sdk.file.list({ path: "." }))
const fileStatus = await capture(sdk.file.status())
const findFiles = await capture(sdk.find.files({ query: "hello", limit: 10 }))
const findText = await capture(sdk.find.text({ pattern: "sdk-parity" }))
const agents = await capture(sdk.app.agents())
const skills = await capture(sdk.app.skills())
const tools = await capture(sdk.tool.ids())
const vcs = await capture(sdk.vcs.get())
const formatter = await capture(sdk.formatter.status())
const lsp = await capture(sdk.lsp.status())
return {
statuses: statuses({
project,
projects,
paths,
config,
providers,
file,
files,
fileStatus,
findFiles,
findText,
agents,
skills,
tools,
vcs,
formatter,
lsp,
}),
project: {
worktreeSelected: record(project.data).worktree === directory,
},
paths: {
cwdSelected: record(paths.data).cwd === directory,
},
file: record(file.data).content,
hasProject: array(projects.data).length > 0,
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
listedFile: JSON.stringify(files.data).includes("hello.txt"),
}
}),
)
})
test("matches generated SDK session lifecycle routes across backends", async () => {
await compareBackends((backend) =>
withTmp(backend, async ({ sdk }) => {
const parent = await capture(sdk.session.create({ title: "parent" }))
const parentID = String(record(parent.data).id)
const child = await capture(sdk.session.create({ title: "child", parentID }))
const childID = String(record(child.data).id)
const get = await capture(sdk.session.get({ sessionID: parentID }))
const update = await capture(sdk.session.update({ sessionID: parentID, title: "renamed" }))
const roots = await capture(sdk.session.list({ roots: true, limit: 10 }))
const all = await capture(sdk.session.list({ roots: false, limit: 10 }))
const children = await capture(sdk.session.children({ sessionID: parentID }))
const todo = await capture(sdk.session.todo({ sessionID: parentID }))
const status = await capture(sdk.session.status())
const messages = await capture(sdk.session.messages({ sessionID: parentID }))
const missingGet = await capture(sdk.session.get({ sessionID: "ses_missing" }))
const missingMessages = await capture(sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
const invalidCursor = await capture(sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }))
const deleted = await capture(sdk.session.delete({ sessionID: childID }))
const getDeleted = await capture(sdk.session.get({ sessionID: childID }))
return {
statuses: statuses({
parent,
child,
get,
update,
roots,
all,
children,
todo,
status,
messages,
missingGet,
missingMessages,
invalidCursor,
deleted,
getDeleted,
}),
getTitle: record(get.data).title,
updatedTitle: record(update.data).title,
rootTitles: sessionTitles(roots.data),
allTitles: sessionTitles(all.data),
childCount: array(children.data).length,
todoCount: array(todo.data).length,
messageCount: array(messages.data).length,
}
}),
)
})
test("matches generated SDK session message and part routes across backends", async () => {
await compareBackends((backend) =>
withTmp(backend, async ({ sdk, directory }) => {
const session = await capture(sdk.session.create({ title: "messages" }))
const sessionID = String(record(session.data).id)
const seeded = await seedMessage(directory, sessionID)
const list = await capture(sdk.session.messages({ sessionID }))
const page = await capture(sdk.session.messages({ sessionID, limit: 1 }))
const message = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
const partUpdate = await capture(
sdk.part.update({
sessionID,
messageID: seeded.message.id,
partID: seeded.part.id,
part: {
...seeded.part,
text: "updated message",
} as NonNullable<Parameters<Sdk["part"]["update"]>[0]["part"]>,
}),
)
const updated = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
const partDelete = await capture(
sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
)
const withoutPart = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
const deleteMessage = await capture(sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }))
const missingMessage = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id }))
return {
statuses: statuses({
session,
list,
page,
message,
partUpdate,
updated,
partDelete,
withoutPart,
deleteMessage,
missingMessage,
}),
listCount: array(list.data).length,
pageCount: array(page.data).length,
initialText: firstPartText(message.data),
updatedText: firstPartText(updated.data),
partCountAfterDelete: array(record(withoutPart.data).parts).length,
}
}),
)
})
test("matches generated SDK prompt no-reply routes across backends", async () => {
await compareBackends((backend) =>
withTmp(backend, async ({ sdk }) => {
const session = await capture(sdk.session.create({ title: "prompt" }))
const sessionID = String(record(session.data).id)
const prompt = await capture(
sdk.session.prompt({
sessionID,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
const asyncPrompt = await capture(
sdk.session.promptAsync({
sessionID,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "async hello" }],
}),
)
const messages = await capture(sdk.session.messages({ sessionID }))
return {
statuses: statuses({ session, prompt, asyncPrompt, messages }),
promptRole: record(record(prompt.data).info).role,
messageCount: array(messages.data).length,
messageTexts: array(messages.data)
.flatMap((item) => array(record(item).parts))
.map((part) => record(part).text)
.filter((text): text is string => typeof text === "string")
.sort(),
}
}),
)
})
test("matches generated SDK prompt streaming through fake LLM across backends", async () => {
await compareBackends((backend) =>
withFakeLlm(backend, async ({ sdk, llm }) => {
await Effect.runPromise(llm.text("fake world", { usage: { input: 11, output: 7 } }))
const session = await capture(
sdk.session.create({
title: "llm prompt",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const sessionID = String(record(session.data).id)
const prompt = await capture(
sdk.session.prompt({
sessionID,
agent: "build",
model: { providerID: "test", modelID: "test-model" },
parts: [{ type: "text", text: "hello llm" }],
}),
)
const messages = await capture(sdk.session.messages({ sessionID }))
const inputs = await Effect.runPromise(llm.inputs)
return {
statuses: statuses({ session, prompt, messages }),
calls: inputs.length,
requestedModel: inputs[0]?.model,
responseText: JSON.stringify(prompt.data).includes("fake world"),
persistedText: JSON.stringify(messages.data).includes("fake world"),
userText: JSON.stringify(messages.data).includes("hello llm"),
}
}),
)
})
test("matches generated SDK TUI validation and command routes across backends", async () => {
await compareBackends((backend) =>
withTmp(backend, async ({ sdk }) => {
const session = await capture(sdk.session.create({ title: "tui" }))
const sessionID = String(record(session.data).id)
const appendPrompt = await capture(sdk.tui.appendPrompt({ text: "hello" }))
const openHelp = await capture(sdk.tui.openHelp())
const openSessions = await capture(sdk.tui.openSessions())
const openThemes = await capture(sdk.tui.openThemes())
const openModels = await capture(sdk.tui.openModels())
const submitPrompt = await capture(sdk.tui.submitPrompt())
const clearPrompt = await capture(sdk.tui.clearPrompt())
const executeCommand = await capture(sdk.tui.executeCommand({ command: "session_new" }))
const showToast = await capture(sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" }))
const selectSession = await capture(sdk.tui.selectSession({ sessionID }))
const missingSession = await capture(sdk.tui.selectSession({ sessionID: "ses_missing" }))
const invalidSession = await capture(sdk.tui.selectSession({ sessionID: "invalid_session_id" }))
return {
statuses: statuses({
session,
appendPrompt,
openHelp,
openSessions,
openThemes,
openModels,
submitPrompt,
clearPrompt,
executeCommand,
showToast,
selectSession,
missingSession,
invalidSession,
}),
data: {
appendPrompt: appendPrompt.data,
openHelp: openHelp.data,
openSessions: openSessions.data,
openThemes: openThemes.data,
openModels: openModels.data,
submitPrompt: submitPrompt.data,
clearPrompt: clearPrompt.data,
executeCommand: executeCommand.data,
showToast: showToast.data,
selectSession: selectSession.data,
},
}
}),
)
})
test("matches generated SDK project git initialization across backends", async () => {
await compareBackends(async (backend) => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const sdk = client(backend, tmp.path)
const before = await capture(sdk.project.current())
const init = await capture(sdk.project.initGit())
const after = await capture(sdk.project.current())
return {
statuses: statuses({ before, init, after }),
before: {
vcs: record(before.data).vcs ?? null,
worktree: record(before.data).worktree,
},
init: {
vcs: record(init.data).vcs,
worktreeSelected: record(init.data).worktree === tmp.path,
},
after: {
vcs: record(after.data).vcs,
worktreeSelected: record(after.data).worktree === tmp.path,
},
}
})
})
}) })
@@ -5,7 +5,7 @@ import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
@@ -16,7 +16,7 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
function app(httpapi = true) { function app(httpapi = true) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
return Server.Default().app return httpapi ? Server.Default().app : Server.Legacy().app
} }
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) { function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -3,7 +3,7 @@ import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui" import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui"
import { callTui } from "../../src/server/routes/instance/tui" import { callTui } from "../../src/server/routes/instance/tui"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Effect } from "effect" import { Effect } from "effect"
@@ -6,13 +6,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdaptor } from "../../src/control-plane/adaptors" import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types" import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { Session } from "@/session/session" import { Session } from "@/session/session"
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"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -54,7 +55,41 @@ function localAdaptor(directory: string): WorkspaceAdaptor {
} }
} }
function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor {
return {
name: "Remote Test",
description: "Create a remote test workspace",
configure(info) {
return {
...info,
name: "remote-test",
directory,
}
},
async create() {
await mkdir(directory, { recursive: true })
},
async remove() {},
target() {
return {
type: "remote" as const,
url,
}
},
}
}
function eventStreamResponse() {
return new Response(new ReadableStream({ start() {} }), {
status: 200,
headers: {
"content-type": "text/event-stream",
},
})
}
afterEach(async () => { afterEach(async () => {
mock.restore()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
await Instance.disposeAll() await Instance.disposeAll()
@@ -125,4 +160,81 @@ describe("workspace HttpApi", () => {
expect(listed.status).toBe(200) expect(listed.status).toBe(200)
expect(await listed.json()).toEqual([]) expect(await listed.json()).toEqual([])
}) })
test("routes local workspace requests through the workspace target directory", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true })
const workspaceDir = path.join(tmp.path, ".workspace-local")
const workspace = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir))
return Workspace.create({
type: "local-target",
branch: null,
extra: null,
projectID: Instance.project.id,
})
},
})
const url = new URL(`http://localhost${InstancePaths.path}`)
url.searchParams.set("workspace", workspace.id)
try {
const response = await request(url.toString(), tmp.path)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ directory: workspaceDir })
} finally {
await Workspace.remove(workspace.id)
}
})
test("proxies remote workspace HTTP requests", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true })
const proxied: string[] = []
const rawFetch = globalThis.fetch
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
proxied.push(url.toString())
return Response.json({ proxied: true, path: url.pathname, workspace: url.searchParams.get("workspace") })
},
{
preconnect: rawFetch.preconnect?.bind(rawFetch),
},
) as typeof globalThis.fetch,
)
const workspace = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "remote-target", remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base"))
return Workspace.create({
type: "remote-target",
branch: null,
extra: null,
projectID: Instance.project.id,
})
},
})
const url = new URL(`http://localhost${InstancePaths.path}`)
url.searchParams.set("workspace", workspace.id)
try {
const response = await request(url.toString(), tmp.path)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ proxied: true, path: "/base/path", workspace: null })
expect(proxied).toEqual(["https://remote.test/base/path"])
} finally {
await Workspace.remove(workspace.id)
}
})
}) })
@@ -43,7 +43,9 @@ Output: Creates directory 'foo'"
}, },
"timeout": { "timeout": {
"description": "Optional timeout in milliseconds", "description": "Optional timeout in milliseconds",
"type": "number", "exclusiveMinimum": 0,
"maximum": 9007199254740991,
"type": "integer",
}, },
"workdir": { "workdir": {
"description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.", "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
@@ -71,7 +73,7 @@ exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = `
"description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", "description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
"maximum": 50000, "maximum": 50000,
"minimum": 1000, "minimum": 1000,
"type": "number", "type": "integer",
}, },
}, },
"required": [ "required": [
@@ -224,7 +226,6 @@ exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
} }
`; `;
exports[`tool parameters JSON Schema (wire shape) plan 1`] = ` exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -304,11 +305,15 @@ exports[`tool parameters JSON Schema (wire shape) read 1`] = `
}, },
"limit": { "limit": {
"description": "The maximum number of lines to read (defaults to 2000)", "description": "The maximum number of lines to read (defaults to 2000)",
"type": "number", "maximum": 9007199254740991,
"minimum": 0,
"type": "integer",
}, },
"offset": { "offset": {
"description": "The line number to start reading from (1-indexed)", "description": "The line number to start reading from (1-indexed)",
"type": "number", "maximum": 9007199254740991,
"minimum": 0,
"type": "integer",
}, },
}, },
"required": [ "required": [