Compare commits

..
75 changed files with 375 additions and 287 deletions
+21 -1
View File
@@ -1,5 +1,8 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"categories": {
"suspicious": "warn"
},
"rules": {
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off",
@@ -10,7 +13,24 @@
// Intentional control char matching (ANSI escapes, null byte sanitization)
"no-control-regex": "off",
// SST and plugin tools require triple-slash references
"triple-slash-reference": "off"
"triple-slash-reference": "off",
// Suspicious category: suppress noisy rules
// Effect's nested function* closures inherently shadow outer scope
"no-shadow": "off",
// Namespace-heavy codebase makes this too noisy
"unicorn/consistent-function-scoping": "off",
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
"unicorn/no-array-sort": "off",
"unicorn/no-array-reverse": "off",
// Not relevant — this isn't a DOM event handler codebase
"unicorn/prefer-add-event-listener": "off",
// Bundler handles module resolution
"unicorn/require-module-specifiers": "off",
// postMessage target origin not relevant for this codebase
"unicorn/require-post-message-target-origin": "off",
// Side-effectful constructors are intentional in some places
"no-new": "off"
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
}
+2 -2
View File
@@ -542,7 +542,7 @@ async function subscribeSessionEvents() {
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
console.log(`${color}|`, `\x1b[0m\x1b[2m ${tool.padEnd(7, " ")}`, "", `\x1b[0m${title}`)
}
if (part.type === "text") {
@@ -776,7 +776,7 @@ async function assertPermissions() {
console.log(` permission: ${permission}`)
} catch (error) {
console.error(`Failed to check permissions: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
+1
View File
@@ -128,6 +128,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (started) return run
started = true
run = (async () => {
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
@@ -46,7 +46,9 @@ describe("runtime adapters", () => {
})
test("resolves speech recognition constructor with webkit precedence", () => {
// oxlint-disable-next-line no-extraneous-class
class SpeechCtor {}
// oxlint-disable-next-line no-extraneous-class
class WebkitCtor {}
const ctor = getSpeechRecognitionCtor({
SpeechRecognition: SpeechCtor,
@@ -90,7 +90,7 @@ export function ReloadSection() {
}
const info = billingInfo()!
setStore("show", true)
setStore("reload", info.reload ? true : true)
setStore("reload", true)
setStore("reloadAmount", info.reloadAmount.toString())
setStore("reloadTrigger", info.reloadTrigger.toString())
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
return output.toString().trim()
} catch (error) {
throw new Error(`Failed to run wslpath: ${String(error)}`)
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
}
}
+1
View File
@@ -13,6 +13,7 @@ type Env = {
}
export class SyncServer extends DurableObject<Env> {
// oxlint-disable-next-line no-useless-constructor
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
}
+1 -1
View File
@@ -64,7 +64,7 @@ function findBinary() {
return { binaryPath, binaryName }
} catch (error) {
throw new Error(`Could not find package ${packageName}: ${error.message}`)
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
}
}
+62 -20
View File
@@ -121,17 +121,46 @@ Why `question` first:
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
### 4. Build in parallel, do not bridge into Hono
### 4. Bridge into Hono behind a feature flag
The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`.
The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means:
The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`.
- one process, one port — no separate server
- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.)
- Effect middleware handles auth and instance lookup independently from Hono middleware
- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers
The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes.
The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged.
### 5. Migrate JSON route groups gradually
```ts
// in instance/index.ts
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
}
```
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first.
### 5. Observability
The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost.
This gives:
- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans
- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger`
### 6. Migrate JSON route groups gradually
As each route group is ported to `HttpApi`:
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Schema rule for HttpApi work
@@ -302,36 +331,43 @@ The first slice is successful if:
- OpenAPI is generated from the `HttpApi` contract
- the tests are straightforward enough that the next slice feels mechanical
## Learnings from the question slice
## Learnings
The first parallel `question` spike gave us a concrete pattern to reuse.
### Schema
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged.
- compare generated OpenAPI semantically at the route and schema level.
- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
### Integration
- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.
- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost.
- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel.
- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead.
- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.
## Route inventory
Status legend:
- `done` - parallel `HttpApi` slice exists
- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag
- `done` - Effect HttpApi slice exists but not yet bridged
- `next` - good near-term candidate
- `later` - possible, but not first wave
- `defer` - not a good early `HttpApi` target
Current instance route inventory:
- `question` - `done`
endpoints in slice: `GET /question`, `POST /question/:requestID/reply`
- `permission` - `done`
endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `next`
best next endpoint: `GET /provider/auth`
later endpoint: `GET /provider`
defer first-wave OAuth mutations
- `question` - `bridged`
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial)
bridged endpoint: `GET /provider/auth`
not yet ported: `GET /provider`, OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
later endpoint: `GET /config`
@@ -371,7 +407,13 @@ Recommended near-term sequence after the first spike:
- [x] keep the underlying service calls identical to the current handlers
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap`
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
+1 -1
View File
@@ -20,7 +20,7 @@ import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
+1 -1
View File
@@ -25,7 +25,7 @@ export namespace BusEvent {
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
ref: `Event.${def.type}`,
})
})
.toArray()
+2 -2
View File
@@ -1,10 +1,10 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { EffectBridge } from "@/effect"
import { Log } from "../util/log"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
@@ -111,6 +111,7 @@ function parseToolParams(input?: string) {
} catch (evalError) {
throw new Error(
`Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`,
{ cause: evalError },
)
}
}
+2 -1
View File
@@ -1031,6 +1031,7 @@ export const GithubRunCommand = cmd({
console.error("Failed to get OIDC token:", error instanceof Error ? error.message : error)
throw new Error(
"Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions.",
{ cause: error },
)
}
}
@@ -1221,7 +1222,7 @@ export const GithubRunCommand = cmd({
console.log(` permission: ${permission}`)
} catch (error) {
console.error(`Failed to check permissions: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
+1 -1
View File
@@ -34,7 +34,7 @@ export const WebCommand = cmd({
describe: "start opencode server and open web interface",
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
+2 -2
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect"
import { EffectBridge } from "@/effect"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect"
+1 -1
View File
@@ -32,7 +32,7 @@ import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
+1 -1
View File
@@ -10,7 +10,7 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -1,4 +1,4 @@
import { LocalContext } from "../util"
import { LocalContext } from "../util/local-context"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
+36 -38
View File
@@ -1,49 +1,47 @@
import { Effect, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance"
import { LocalContext } from "@/util"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service"
export namespace EffectBridge {
export interface Shape {
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
}
export interface Shape {
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
}
function restore<R>(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R {
if (instance && workspace !== undefined) {
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
}
if (instance) return Instance.restore(instance, fn)
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
return fn()
function restore<R>(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R {
if (instance && workspace !== undefined) {
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
}
if (instance) return Instance.restore(instance, fn)
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
return fn()
}
export function make(): Effect.Effect<Shape> {
return Effect.gen(function* () {
const ctx = yield* Effect.context()
const value = yield* InstanceRef
const instance =
value ??
(() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
const attach = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
export function make(): Effect.Effect<Shape> {
return Effect.gen(function* () {
const ctx = yield* Effect.context()
const value = yield* InstanceRef
const instance =
value ??
(() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
const workspace = (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
const attach = <A, E, R>(effect: Effect.Effect<A, E, R>) => attachWith(effect, { instance, workspace })
const wrap = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
attach(effect).pipe(Effect.provide(ctx)) as Effect.Effect<A, E, never>
return {
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
} satisfies Shape
})
}
return {
promise: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
} satisfies Shape
})
}
+2
View File
@@ -0,0 +1,2 @@
export * as InstanceState from "./instance-state"
export * as EffectBridge from "./bridge"
+65 -68
View File
@@ -1,7 +1,7 @@
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { Instance, type InstanceContext } from "@/project/instance"
import { LocalContext } from "@/util"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -13,72 +13,69 @@ export interface InstanceState<A, E = never, R = never> {
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export namespace InstanceState {
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
try {
return Instance.bind(fn)
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
const fiber = Fiber.getCurrent()
const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
if (!ctx) return fn
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
try {
return Instance.bind(fn)
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
export const context = Effect.gen(function* () {
return (yield* InstanceRef) ?? Instance.current
})
export const workspaceID = Effect.gen(function* () {
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
})
export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = <A, E = never, R = never>(
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: () =>
Effect.gen(function* () {
return yield* init(yield* context)
}),
})
const off = registerDisposer((directory) =>
Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))),
)
yield* Effect.addFinalizer(() => Effect.sync(off))
return {
[TypeId]: TypeId,
cache,
}
})
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.gen(function* () {
return yield* ScopedCache.get(self.cache, yield* directory)
})
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
Effect.map(get(self), select)
export const useEffect = <A, E, R, B, E2, R2>(
self: InstanceState<A, E, R>,
select: (value: A) => Effect.Effect<B, E2, R2>,
) => Effect.flatMap(get(self), select)
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.gen(function* () {
return yield* ScopedCache.has(self.cache, yield* directory)
})
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.gen(function* () {
return yield* ScopedCache.invalidate(self.cache, yield* directory)
})
const fiber = Fiber.getCurrent()
const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
if (!ctx) return fn
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
}
export const context = Effect.gen(function* () {
return (yield* InstanceRef) ?? Instance.current
})
export const workspaceID = Effect.gen(function* () {
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
})
export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = <A, E = never, R = never>(
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: () =>
Effect.gen(function* () {
return yield* init(yield* context)
}),
})
const off = registerDisposer((directory) =>
Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))),
)
yield* Effect.addFinalizer(() => Effect.sync(off))
return {
[TypeId]: TypeId,
cache,
}
})
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.gen(function* () {
return yield* ScopedCache.get(self.cache, yield* directory)
})
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) => Effect.map(get(self), select)
export const useEffect = <A, E, R, B, E2, R2>(
self: InstanceState<A, E, R>,
select: (value: A) => Effect.Effect<B, E2, R2>,
) => Effect.flatMap(get(self), select)
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.gen(function* () {
return yield* ScopedCache.has(self.cache, yield* directory)
})
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.gen(function* () {
return yield* ScopedCache.invalidate(self.cache, yield* directory)
})
+1 -1
View File
@@ -1,7 +1,7 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./observability"
import { WorkspaceContext } from "@/control-plane/workspace-context"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Context, Effect, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
type State = Record<string, string | undefined>
+1 -1
View File
@@ -1,5 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Git } from "@/git"
+1 -1
View File
@@ -1,5 +1,5 @@
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
+1 -1
View File
@@ -7,7 +7,7 @@ import path from "path"
import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Flag } from "@/flag/flag"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
+1
View File
@@ -84,6 +84,7 @@ export namespace Flag {
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
export const OPENCODE_WORKSPACE_ID = process.env["OPENCODE_WORKSPACE_ID"]
export const OPENCODE_EXPERIMENTAL_HTTPAPI = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HTTPAPI")
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
function number(key: string) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { Effect, Layer, Context } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
+1 -1
View File
@@ -12,7 +12,7 @@ import { Flag } from "@/flag/flag"
import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
export namespace LSP {
const log = Log.create({ service: "lsp" })
+1 -1
View File
@@ -8,7 +8,7 @@ import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util/archive"
import { Archive } from "../util"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/shared/util/module"
+2 -2
View File
@@ -25,8 +25,8 @@ import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+1 -1
View File
@@ -313,7 +313,7 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile
try {
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`)
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
let originalLines = originalContent.split("\n")
@@ -1,7 +1,7 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
+2 -2
View File
@@ -18,8 +18,8 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
+1 -1
View File
@@ -4,7 +4,7 @@ import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { LocalContext } from "../util"
import { LocalContext } from "../util/local-context"
import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
+1 -1
View File
@@ -3,7 +3,7 @@ import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
+1 -1
View File
@@ -1,7 +1,7 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { Plugin } from "../plugin"
+2 -2
View File
@@ -19,8 +19,8 @@ import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
import { Effect, Layer, Context } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { isRecord } from "@/util/record"
+2 -2
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Instance } from "@/project/instance"
import type { Proc } from "#pty"
import z from "zod"
@@ -10,7 +10,7 @@ import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, Context } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { EffectBridge } from "@/effect"
const log = Log.create({ service: "pty" })
+1 -1
View File
@@ -1,7 +1,7 @@
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { SessionID, MessageID } from "@/session/schema"
import { zod } from "@/util/effect-zod"
import { Log } from "@/util/log"
@@ -3,7 +3,7 @@ import { PermissionID } from "@/permission/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/httpapi/permission"
const root = "/permission"
export const PermissionApi = HttpApi.make("permission")
.add(
@@ -45,7 +45,7 @@ export const PermissionApi = HttpApi.make("permission")
}),
)
export const PermissionLive = Layer.unwrap(
export const permissionHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Permission.Service
@@ -2,7 +2,7 @@ import { ProviderAuth } from "@/provider/auth"
import { Effect, Layer } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/httpapi/provider"
const root = "/provider"
export const ProviderApi = HttpApi.make("provider")
.add(
@@ -33,7 +33,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
)
export const ProviderLive = Layer.unwrap(
export const providerHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* ProviderAuth.Service
@@ -3,7 +3,7 @@ import { QuestionID } from "@/question/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/httpapi/question"
const root = "/question"
export const QuestionApi = HttpApi.make("question")
.add(
@@ -29,23 +29,33 @@ export const QuestionApi = HttpApi.make("question")
description: "Provide answers to a question request from the AI assistant.",
}),
),
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
params: { requestID: QuestionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reject",
summary: "Reject question request",
description: "Reject a question request from the AI assistant.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "question",
description: "Experimental HttpApi question routes.",
description: "Question routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
title: "opencode HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
description: "Effect HttpApi surface for instance routes.",
}),
)
export const QuestionLive = Layer.unwrap(
export const questionHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Question.Service
@@ -64,8 +74,13 @@ export const QuestionLive = Layer.unwrap(
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
yield* svc.reject(ctx.params.requestID)
return true
})
return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
handlers.handle("list", list).handle("reply", reply),
handlers.handle("list", list).handle("reply", reply).handle("reject", reject),
)
}),
).pipe(Layer.provide(Question.defaultLayer))
@@ -1,20 +1,18 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer, Redacted, Schema } from "effect"
import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Observability } from "@/effect/observability"
import { memoMap } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { Permission } from "@/permission"
import { ProviderAuth } from "@/provider/auth"
import { Question } from "@/question"
import { PermissionApi, PermissionLive } from "./permission"
import { ProviderApi, ProviderLive } from "./provider"
import { QuestionApi, QuestionLive } from "./question"
import { PermissionApi, permissionHandlers } from "./permission"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
@@ -113,26 +111,20 @@ export namespace ExperimentalHttpApiServer {
const ProviderSecured = ProviderApi.middleware(Authorization)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe(
Layer.provide(QuestionLive),
),
HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe(
Layer.provide(PermissionLive),
),
HttpApiBuilder.layer(ProviderSecured, { openapiPath: "/experimental/httpapi/provider/doc" }).pipe(
Layer.provide(ProviderLive),
),
).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance))
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
).pipe(
Layer.provide(auth),
Layer.provide(normalize),
Layer.provide(instance),
Layer.provide(HttpServer.layerServices),
Layer.provideMerge(Observability.layer),
)
export const layer = (opts: { hostname: string; port: number }) =>
HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })),
)
export const layerTest = HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(Question.defaultLayer),
Layer.provideMerge(Permission.defaultLayer),
Layer.provideMerge(ProviderAuth.defaultLayer),
export const webHandler = lazy(() =>
HttpRouter.toWebHandler(routes, {
memoMap,
}),
)
}
+17 -2
View File
@@ -14,6 +14,8 @@ import { LSP } from "../../lsp"
import { Command } from "../../command"
import { QuestionRoutes } from "./question"
import { PermissionRoutes } from "./permission"
import { Flag } from "@/flag/flag"
import { ExperimentalHttpApiServer } from "./httpapi/server"
import { ProjectRoutes } from "./project"
import { SessionRoutes } from "./session"
import { PtyRoutes } from "./pty"
@@ -27,8 +29,8 @@ import { SyncRoutes } from "./sync"
import { WorkspaceRouterMiddleware } from "./middleware"
import { AppRuntime } from "@/effect/app-runtime"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
new Hono()
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
const app = new Hono()
.use(WorkspaceRouterMiddleware(upgrade))
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes(upgrade))
@@ -36,6 +38,18 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
.route("/permission", PermissionRoutes())
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
app
.all("/question", (c) => handler(c.req.raw))
.all("/question/*", (c) => handler(c.req.raw))
.all("/permission", (c) => handler(c.req.raw))
.all("/permission/*", (c) => handler(c.req.raw))
.all("/provider/auth", (c) => handler(c.req.raw))
}
return app
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/sync", SyncRoutes())
@@ -283,3 +297,4 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
},
)
}
+1 -1
View File
@@ -339,7 +339,7 @@ export const TuiRoutes = lazy(() =>
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
ref: `Event.${def.type}`,
})
}),
),
+1 -1
View File
@@ -14,7 +14,7 @@ import { Config } from "@/config"
import { NotFoundError } from "@/storage/db"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { isOverflow as overflow } from "./overflow"
export namespace SessionCompaction {
+1 -1
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
+1 -1
View File
@@ -20,7 +20,7 @@ import { Wildcard } from "@/util/wildcard"
import { SessionID } from "@/session/schema"
import { Auth } from "@/auth"
import { Installation } from "@/installation"
import { EffectBridge } from "@/effect/bridge"
import { EffectBridge } from "@/effect"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
+3 -4
View File
@@ -45,10 +45,10 @@ import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
import { EffectBridge } from "@/effect/bridge"
import { EffectBridge } from "@/effect"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -260,8 +260,7 @@ export namespace SessionPrompt {
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text:
BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`,
text: `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`,
synthetic: true,
})
userMessage.parts.push(part)
+1 -1
View File
@@ -1,4 +1,4 @@
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Runner } from "@/effect/runner"
import { Effect, Layer, Scope, Context } from "effect"
import { Session } from "."
+1 -1
View File
@@ -18,7 +18,7 @@ import { Log } from "../util/log"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
+1 -1
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
+2 -2
View File
@@ -3,7 +3,7 @@ import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "eff
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Account } from "@/account"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session"
@@ -142,7 +142,7 @@ export namespace ShareNext {
})
}
const state: InstanceState<State> = yield* InstanceState.make<State>(
const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>(
Effect.fn("ShareNext.state")(function* (_ctx) {
const cache: State = { queue: new Map(), scope: yield* Scope.make() }
+1 -1
View File
@@ -6,7 +6,7 @@ import { Effect, Layer, Context } from "effect"
import { NamedError } from "@opencode-ai/shared/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission"
+1 -1
View File
@@ -4,7 +4,7 @@ import { formatPatch, structuredPatch } from "diff"
import path from "path"
import z from "zod"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Hash } from "@opencode-ai/shared/util/hash"
import { Config } from "../config"
+2 -2
View File
@@ -2,7 +2,7 @@ import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
import { LocalContext } from "../util"
import { LocalContext } from "../util/local-context"
import { lazy } from "../util/lazy"
import { Global } from "../global"
import { Log } from "../util/log"
@@ -12,7 +12,7 @@ import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import { Flag } from "../flag/flag"
import { CHANNEL } from "../installation/meta"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { iife } from "@/util/iife"
import { init } from "#db"
+1 -1
View File
@@ -273,7 +273,7 @@ export function payloads() {
data: def.schema,
})
.meta({
ref: "SyncEvent" + "." + def.type,
ref: `SyncEvent.${def.type}`,
})
})
.toArray()
@@ -1,7 +1,7 @@
import path from "path"
import { Effect } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path"
import z from "zod"
import { Effect, Option } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory"
+1 -1
View File
@@ -1,7 +1,7 @@
import path from "path"
import z from "zod"
import { Effect, Option } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory"
+1 -1
View File
@@ -181,7 +181,7 @@ export const ReadTool = Tool.define(
)
}
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>" + "\n"].join("\n")
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>\n"].join("\n")
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
const last = file.offset + file.raw.length - 1
+1 -1
View File
@@ -35,7 +35,7 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import { Question } from "../question"
import { Todo } from "../session/todo"
import { LSP } from "../lsp"
+1 -1
View File
@@ -78,7 +78,7 @@ export namespace Tool {
) {
return () =>
Effect.gen(function* () {
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
const execute = toolInfo.execute
toolInfo.execute = (args, ctx) => {
const attrs = {
+10 -12
View File
@@ -1,17 +1,15 @@
import path from "path"
import { Process } from "./process"
export namespace Archive {
export async function extractZip(zipPath: string, destDir: string) {
if (process.platform === "win32") {
const winZipPath = path.resolve(zipPath)
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
}
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
export async function extractZip(zipPath: string, destDir: string) {
if (process.platform === "win32") {
const winZipPath = path.resolve(zipPath)
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
}
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
}
+14 -16
View File
@@ -1,19 +1,17 @@
export namespace Color {
export function isValidHex(hex?: string): hex is string {
if (!hex) return false
return /^#[0-9a-fA-F]{6}$/.test(hex)
}
export function isValidHex(hex?: string): hex is string {
if (!hex) return false
return /^#[0-9a-fA-F]{6}$/.test(hex)
}
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return { r, g, b }
}
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return { r, g, b }
}
export function hexToAnsiBold(hex?: string): string | undefined {
if (!isValidHex(hex)) return undefined
const { r, g, b } = hexToRgb(hex)
return `\x1b[38;2;${r};${g};${b}m\x1b[1m`
}
export function hexToAnsiBold(hex?: string): string | undefined {
if (!isValidHex(hex)) return undefined
const { r, g, b } = hexToRgb(hex)
return `\x1b[38;2;${r};${g};${b}m\x1b[1m`
}
+2 -1
View File
@@ -1 +1,2 @@
export * as LocalContext from "./local-context"
export * as Archive from "./archive"
export * as Color from "./color"
+19 -17
View File
@@ -1,23 +1,25 @@
import { AsyncLocalStorage } from "async_hooks"
export class NotFound extends Error {
constructor(public override readonly name: string) {
super(`No context found for ${name}`)
export namespace LocalContext {
export class NotFound extends Error {
constructor(public override readonly name: string) {
super(`No context found for ${name}`)
}
}
}
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>()
return {
use() {
const result = storage.getStore()
if (!result) {
throw new NotFound(name)
}
return result
},
provide<R>(value: T, fn: () => R) {
return storage.run(value, fn)
},
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>()
return {
use() {
const result = storage.getStore()
if (!result) {
throw new NotFound(name)
}
return result
},
provide<R>(value: T, fn: () => R) {
return storage.run(value, fn)
},
}
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
const log = Log.create({ service: "worktree" })
@@ -5,7 +5,7 @@ import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Config } from "../../src/config"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Color } from "../../src/util/color"
import { Color } from "../../src/util"
import { AppRuntime } from "../../src/effect/app-runtime"
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { Context, Effect, Layer, Logger } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { EffectBridge } from "../../src/effect/bridge"
import { EffectBridge } from "../../src/effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { EffectLogger } from "../../src/effect/logger"
import { makeRuntime } from "../../src/effect/run-service"
@@ -1,11 +1,11 @@
import { afterEach, expect, test } from "bun:test"
import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect"
import { InstanceState } from "../../src/effect/instance-state"
import { InstanceState } from "../../src/effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
async function access<A, E>(state: InstanceState.InstanceState<A, E>, dir: string) {
return Instance.provide({
directory: dir,
fn: () => Effect.runPromise(InstanceState.get(state)),
@@ -53,6 +53,7 @@ function getOrCreateClientState(name?: string): MockClientState {
class MockStdioTransport {
stderr: null = null
pid = 12345
// oxlint-disable-next-line no-useless-constructor
constructor(_opts: any) {}
async start() {
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
@@ -64,6 +65,7 @@ class MockStdioTransport {
}
class MockStreamableHTTP {
// oxlint-disable-next-line no-useless-constructor
constructor(_url: URL, _opts?: any) {}
async start() {
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
@@ -76,6 +78,7 @@ class MockStreamableHTTP {
}
class MockSSE {
// oxlint-disable-next-line no-useless-constructor
constructor(_url: URL, _opts?: any) {}
async start() {
if (connectShouldHang) return new Promise<void>(() => {}) // never resolves
@@ -60,12 +60,11 @@ function chat(text: string) {
function hanging(ready: () => void) {
const encoder = new TextEncoder()
let timer: ReturnType<typeof setTimeout> | undefined
const first =
`data: ${JSON.stringify({
id: "chatcmpl-1",
object: "chat.completion.chunk",
choices: [{ delta: { role: "assistant" } }],
})}` + "\n\n"
const first = `data: ${JSON.stringify({
id: "chatcmpl-1",
object: "chat.completion.chunk",
choices: [{ delta: { role: "assistant" } }],
})}\n\n`
const rest =
[
`data: ${JSON.stringify({