Park remaining local WIP on main. TUI archived, CLI/kernel edits, glass. Sort later. Broken is fine.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,8 +3,8 @@ import { Spec } from "../framework/spec"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "neuron", {
|
||||
description: "Neuron command line interface",
|
||||
commands: [
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
@@ -41,7 +41,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
],
|
||||
}),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
description: "Start the Neuron engine",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Daemon } from "../../services/daemon"
|
||||
|
||||
export default Runtime.handler(Commands, () =>
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const existing = process.env.NEURON_URL
|
||||
const transport = existing
|
||||
? { url: existing, headers: {} }
|
||||
: yield* (yield* Daemon.Service).transport()
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
yield* runTui(transport)
|
||||
}),
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { Context, Layer, Option } from "effect"
|
||||
import { Option } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { createRoutes } from "@opencode-ai/server/routes"
|
||||
import { boot } from "neuron/boot"
|
||||
import { Server } from "neuron/server"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
@@ -18,29 +12,19 @@ export default Runtime.handler(
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
|
||||
if (input.register) yield* daemon.register(address)
|
||||
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
|
||||
const hostname = input.hostname
|
||||
const port = Option.getOrElse(input.port, () => 4096)
|
||||
const { graph, sessions } = yield* Effect.promise(() => boot({ cwd: process.cwd() }))
|
||||
const { server } = Server.serve({
|
||||
port,
|
||||
hostname,
|
||||
deps: { graph, sessions, cwd: process.cwd() },
|
||||
})
|
||||
const url = `http://${hostname}:${server.port}`
|
||||
if (input.register) yield* daemon.register(url)
|
||||
console.log(`neuron engine · ${url}`)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
bind(hostname, port, password).pipe(
|
||||
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node]))),
|
||||
),
|
||||
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import os from "node:os"
|
||||
import path from "path"
|
||||
|
||||
const VERSION = "0.1.0"
|
||||
|
||||
export interface Interface {
|
||||
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
|
||||
readonly client: () => Effect.Effect<{ url: string }, unknown>
|
||||
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
|
||||
readonly start: () => Effect.Effect<string, Error>
|
||||
readonly status: () => Effect.Effect<string | undefined>
|
||||
readonly stop: () => Effect.Effect<void, unknown>
|
||||
readonly password: (value?: string) => Effect.Effect<string, unknown>
|
||||
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
|
||||
readonly register: (url: string) => Effect.Effect<void, unknown, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@neuron/cli/Daemon") {}
|
||||
|
||||
const Registration = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
@@ -36,7 +34,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = Global.Path.state
|
||||
const directory = path.join(os.homedir(), ".neuron")
|
||||
const file = path.join(directory, "server.json")
|
||||
const passwordFile = path.join(directory, "password")
|
||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
||||
@@ -44,9 +42,6 @@ export const layer = Layer.effect(
|
||||
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
|
||||
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (value === undefined && existing) return existing
|
||||
|
||||
// Keep one private credential across server restarts so discovered clients
|
||||
// can reconnect without exposing a password flag or environment variable.
|
||||
const generated = value ?? randomBytes(32).toString("base64url")
|
||||
const temp = passwordFile + ".tmp"
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
@@ -55,26 +50,26 @@ export const layer = Layer.effect(
|
||||
return generated
|
||||
})
|
||||
|
||||
const defaultUrl = () => process.env.NEURON_URL ?? `http://127.0.0.1:${process.env.NEURON_PORT ?? 4096}`
|
||||
|
||||
const registration = Effect.fnUntraced(function* () {
|
||||
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
|
||||
})
|
||||
|
||||
const createClient = Effect.fnUntraced(function* (url: string) {
|
||||
return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) })
|
||||
const probe = Effect.fnUntraced(function* (url: string) {
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/global/health", url), { signal: AbortSignal.timeout(2_000) }),
|
||||
)
|
||||
if (!response.ok) return yield* Effect.fail(new Error("Server is not healthy"))
|
||||
const body = (yield* Effect.tryPromise(() => response.json())) as { ok?: boolean; healthy?: boolean }
|
||||
if (body.ok === true || body.healthy === true) return url
|
||||
return yield* Effect.fail(new Error("Server is not healthy"))
|
||||
})
|
||||
|
||||
const healthy = Effect.fnUntraced(function* () {
|
||||
const info = yield* registration()
|
||||
const client = yield* createClient(info.url)
|
||||
const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) }))
|
||||
if (response.data?.healthy === true) return info
|
||||
return yield* Effect.fail(new Error("Registered server is not healthy"))
|
||||
})
|
||||
|
||||
const compatible = Effect.fnUntraced(function* () {
|
||||
const info = yield* healthy()
|
||||
if (info.version === InstallationVersion) return info
|
||||
return yield* Effect.fail(new Error("Registered server version does not match the client"))
|
||||
yield* probe(info.url)
|
||||
return info
|
||||
})
|
||||
|
||||
const signal = (pid: number, signal: NodeJS.Signals) =>
|
||||
@@ -110,10 +105,14 @@ export const layer = Layer.effect(
|
||||
const start = Effect.fn("cli.daemon.start")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
if (found?.version === InstallationVersion && compiled) return found.url
|
||||
if (found?.version === VERSION) return found.url
|
||||
|
||||
const live = yield* probe(defaultUrl()).pipe(Effect.option)
|
||||
if (Option.isSome(live)) return live.value
|
||||
|
||||
if (found) yield* stopProcess(found).pipe(Effect.ignore)
|
||||
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
const entrypoint = compiled ? undefined : process.argv[1]
|
||||
if (!compiled && entrypoint === undefined)
|
||||
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||
@@ -127,26 +126,25 @@ export const layer = Layer.effect(
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
|
||||
return yield* compatible().pipe(
|
||||
return yield* probe(defaultUrl()).pipe(
|
||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
|
||||
Effect.map((info) => info.url),
|
||||
Effect.mapError(() => new Error("Failed to start server")),
|
||||
Effect.mapError(() => new Error(`Failed to start engine at ${defaultUrl()}`)),
|
||||
)
|
||||
})
|
||||
|
||||
const transport = Effect.fn("cli.daemon.transport")(function* () {
|
||||
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
|
||||
return { url: yield* start(), headers: {} }
|
||||
})
|
||||
|
||||
const client = Effect.fn("cli.daemon.client")(function* () {
|
||||
const connection = yield* transport()
|
||||
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
|
||||
return { url: connection.url }
|
||||
})
|
||||
|
||||
const status = Effect.fn("cli.daemon.status")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
if (found?.version === InstallationVersion) return found.url
|
||||
if (found?.version === VERSION) return found.url
|
||||
if (found) return undefined
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
return undefined
|
||||
@@ -154,20 +152,18 @@ export const layer = Layer.effect(
|
||||
|
||||
const stop = Effect.fn("cli.daemon.stop")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
// A stale registration may point at a PID that has since been reused by
|
||||
// another process. Only signal the PID after authenticating the server.
|
||||
if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
|
||||
yield* stopProcess(existing.value)
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
|
||||
const register = Effect.fn("cli.daemon.register")(function* (url: string) {
|
||||
const id = randomUUID()
|
||||
const temp = file + "." + id + ".tmp"
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
yield* fs.writeFileString(
|
||||
temp,
|
||||
JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }),
|
||||
JSON.stringify({ id, version: VERSION, url, pid: process.pid }),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
yield* fs.rename(temp, file)
|
||||
|
||||
+12
-5
@@ -4,6 +4,7 @@ import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
/** TUI talks HTTP to the Neuron engine. Core is only used here for local TUI host paths. */
|
||||
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
return run({
|
||||
@@ -27,11 +28,17 @@ const legacyDefaults: Record<string, unknown> = {
|
||||
|
||||
const gracefulFetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await fetch(input, init)
|
||||
if (response.status !== 404) return response
|
||||
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
|
||||
if (fallback === undefined) return response
|
||||
return Response.json(fallback)
|
||||
try {
|
||||
const response = await fetch(input, init)
|
||||
if (response.status !== 404) return response
|
||||
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
|
||||
if (fallback === undefined) return response
|
||||
return Response.json(fallback)
|
||||
} catch {
|
||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||
const fallback = legacyDefaults[path]
|
||||
return Response.json(fallback ?? { error: "engine unreachable", path }, { status: 503 })
|
||||
}
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user