diff --git a/bun.lock b/bun.lock index 7d4a912af1e..a692e1119f1 100644 --- a/bun.lock +++ b/bun.lock @@ -101,16 +101,22 @@ "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "effect": "catalog:", + "fuzzysort": "catalog:", "jsonc-parser": "3.3.1", + "opentui-spinner": "catalog:", "semver": "catalog:", "solid-js": "catalog:", + "strip-ansi": "7.1.2", }, "devDependencies": { "@opencode-ai/script": "workspace:*", @@ -599,6 +605,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/cli": "workspace:*", "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -796,6 +803,7 @@ "name": "@opencode-ai/server", "version": "1.17.13", "dependencies": { + "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/simulation": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 122cd993ba0..8cb1c2ef224 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,6 +10,20 @@ "files": [ "bin" ], + "exports": { + "./daemon": "./src/daemon.ts", + "./mini": "./src/mini/index.ts", + "./mini/footer.command": "./src/mini/footer.command.tsx", + "./mini/footer.menu": "./src/mini/footer.menu.tsx", + "./mini/footer.permission": "./src/mini/footer.permission.tsx", + "./mini/footer.prompt": "./src/mini/footer.prompt.tsx", + "./mini/footer.question": "./src/mini/footer.question.tsx", + "./mini/footer.subagent": "./src/mini/footer.subagent.tsx", + "./mini/footer.view": "./src/mini/footer.view.tsx", + "./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx", + "./mini/*": "./src/mini/*.ts", + "./server-process": "./src/server-process.ts" + }, "scripts": { "build": "bun run script/build.ts", "dev": "bun run src/index.ts", @@ -19,16 +33,22 @@ "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "effect": "catalog:", + "fuzzysort": "catalog:", "jsonc-parser": "3.3.1", + "opentui-spinner": "catalog:", "semver": "catalog:", - "solid-js": "catalog:" + "solid-js": "catalog:", + "strip-ansi": "7.1.2" }, "devDependencies": { "@opencode-ai/script": "workspace:*", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index b8101f4ae5f..a3b49b3d06f 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -3,6 +3,31 @@ import { Spec } from "../framework/spec" declare const OPENCODE_CLI_NAME: string | undefined +const MiniParams = { + continue: Flag.boolean("continue").pipe( + Flag.withAlias("c"), + Flag.withDescription("Continue the last session"), + Flag.withDefault(false), + ), + session: Flag.string("session").pipe( + Flag.withAlias("s"), + Flag.withDescription("Session ID to continue"), + Flag.optional, + ), + fork: Flag.boolean("fork").pipe( + Flag.withDescription("Fork the session when continuing"), + Flag.withDefault(false), + ), + replay: Flag.boolean("replay").pipe( + Flag.withDescription("Replay session history on resume and after resize"), + Flag.withDefault(true), + ), + replayLimit: Flag.integer("replay-limit").pipe( + Flag.withDescription("Cap visible replay to the newest N messages"), + Flag.optional, + ), +} + export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { description: "OpenCode 2.0 preview command line interface", params: { @@ -88,6 +113,86 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO ], }), Spec.make("migrate", { description: "Migrate v1 data to v2" }), + Spec.make("mini", { + description: "Start the minimal interactive interface", + params: { + ...MiniParams, + project: Argument.string("project").pipe( + Argument.withDescription("Path to start OpenCode in"), + Argument.optional, + ), + model: Flag.string("model").pipe( + Flag.withAlias("m"), + Flag.withDescription("Model to use in the format provider/model"), + Flag.optional, + ), + agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional), + prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), + server: Flag.string("server").pipe( + Flag.withDescription("Connect to a server URL instead of the background service"), + Flag.optional, + ), + demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden), + }, + }), + Spec.make("run", { + description: "Run OpenCode with a message", + params: { + message: Argument.string("message").pipe( + Argument.withDescription("Message to send"), + Argument.variadic({ min: 0 }), + ), + continue: Flag.boolean("continue").pipe( + Flag.withAlias("c"), + Flag.withDescription("Continue the last session"), + Flag.withDefault(false), + ), + session: Flag.string("session").pipe( + Flag.withAlias("s"), + Flag.withDescription("Session ID to continue"), + Flag.optional, + ), + fork: Flag.boolean("fork").pipe( + Flag.withDescription("Fork the session before continuing"), + Flag.withDefault(false), + ), + model: Flag.string("model").pipe( + Flag.withAlias("m"), + Flag.withDescription("Model to use in the format provider/model"), + Flag.optional, + ), + agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional), + format: Flag.choice("format", ["default", "json"]).pipe( + Flag.withDescription("Output format"), + Flag.withDefault("default"), + ), + file: Flag.string("file").pipe( + Flag.withAlias("f"), + Flag.withDescription("File to attach to the message"), + Flag.atMost(100), + ), + title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional), + server: Flag.string("server").pipe( + Flag.withDescription("Connect to a server URL instead of the background service"), + Flag.optional, + ), + dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional), + variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional), + thinking: Flag.boolean("thinking").pipe( + Flag.withDescription("Show thinking blocks"), + Flag.withDefault(false), + ), + auto: Flag.boolean("auto").pipe( + Flag.withDescription("Auto-approve permissions that are not explicitly denied"), + Flag.withDefault(false), + ), + yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden), + dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe( + Flag.withDefault(false), + Flag.withHidden, + ), + }, + }), Spec.make("service", { description: "Manage the background server", commands: [ diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts new file mode 100644 index 00000000000..410757967f5 --- /dev/null +++ b/packages/cli/src/commands/handlers/mini.ts @@ -0,0 +1,35 @@ +import { Effect, Option, Redacted } from "effect" +import path from "node:path" +import { Commands } from "../commands" +import { Env } from "../../env" +import { Runtime } from "../../framework/runtime" + +export default Runtime.handler(Commands.commands.mini, (input) => + Effect.gen(function* () { + const { runMini } = yield* Effect.promise(() => import("../../mini")) + const project = Option.getOrUndefined(input.project) + const server = Option.getOrUndefined(input.server) + const password = yield* Env.password + yield* Effect.promise(() => + runMini({ + attach: server, + password: password ? Redacted.value(password) : undefined, + directory: + server !== undefined + ? project + : project === undefined + ? process.cwd() + : path.resolve(process.env.PWD ?? process.cwd(), project), + continue: input.continue, + session: Option.getOrUndefined(input.session), + fork: input.fork, + model: Option.getOrUndefined(input.model), + agent: Option.getOrUndefined(input.agent), + prompt: Option.getOrUndefined(input.prompt), + replay: input.replay, + replayLimit: Option.getOrUndefined(input.replayLimit), + demo: input.demo, + }), + ) + }), +) diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts new file mode 100644 index 00000000000..aa09a32ea99 --- /dev/null +++ b/packages/cli/src/commands/handlers/run.ts @@ -0,0 +1,31 @@ +import { Effect, Option, Redacted } from "effect" +import { Commands } from "../commands" +import { Env } from "../../env" +import { Runtime } from "../../framework/runtime" + +export default Runtime.handler(Commands.commands.run, (input) => + Effect.gen(function* () { + const { runNonInteractive } = yield* Effect.promise(() => import("../../mini")) + const password = yield* Env.password + const separator = process.argv.indexOf("--", 2) + yield* Effect.promise(() => + runNonInteractive({ + message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))], + continue: input.continue, + session: Option.getOrUndefined(input.session), + fork: input.fork, + model: Option.getOrUndefined(input.model), + agent: Option.getOrUndefined(input.agent), + format: input.format, + file: [...input.file], + title: Option.getOrUndefined(input.title), + server: Option.getOrUndefined(input.server), + password: password ? Redacted.value(password) : undefined, + directory: Option.getOrUndefined(input.dir), + variant: Option.getOrUndefined(input.variant), + thinking: input.thinking, + dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions, + }), + ) + }), +) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 8d263880c58..bd3e0c12174 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -1,154 +1,16 @@ -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 { Project } from "@opencode-ai/core/project" -import { Global } from "@opencode-ai/core/global" -import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect" -import { HttpRouter, HttpServer } from "effect/unstable/http" -import { createServer } from "node:http" -import { createRoutes } from "@opencode-ai/server/routes" -import { ServerAuth } from "@opencode-ai/server/auth" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Service } from "@opencode-ai/client/effect" -import { Env } from "../../env" -import { ServiceConfig } from "../../services/service-config" -import { Updater } from "../../services/updater" -import { randomBytes, randomUUID } from "crypto" -import path from "path" +import { ServerProcess } from "../../server-process" export default Runtime.handler( Commands.commands.serve, Effect.fn("cli.serve")(function* (input) { - if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home)) - return yield* Effect.scoped( - Effect.gen(function* () { - const standalonePassword = yield* Env.password - // Keep the lease credential out of the environment inherited by any - // process this server spawns. - if (input.stdio) { - delete process.env.OPENCODE_PASSWORD - delete process.env.OPENCODE_SERVER_PASSWORD - } - const config = input.service ? yield* ServiceConfig.read() : {} - const password = input.service - ? yield* ServiceConfig.password() - : standalonePassword - ? Redacted.value(standalonePassword) - : randomBytes(32).toString("base64url") - if (!password) return yield* Effect.fail(new Error("Missing server password")) - const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" - const port = Option.isSome(input.port) - ? input.port - : config.port === undefined - ? Option.none() - : Option.some(config.port) - const address = yield* listen(hostname, port, password) - yield* Effect.tryPromise(() => - createOpencodeClient({ - baseUrl: HttpServer.formatAddress(address), - headers: ServerAuth.headers({ password }), - }).v2.health.get({}), - ) - if (input.service) yield* register(address) - const url = HttpServer.formatAddress(address) - console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) - if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`) - const updater = yield* Updater.Service - yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) - return yield* input.stdio ? waitForStdinClose() : Effect.never - }).pipe(Effect.annotateLogs({ role: "server" })), - ) + if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined")) + return yield* ServerProcess.run({ + mode: input.service ? "service" : input.stdio ? "stdio" : "default", + hostname: Option.getOrUndefined(input.hostname), + port: Option.getOrUndefined(input.port), + }) }), ) - -// Server-side half of the registration protocol. The registration embeds the -// password so the file alone is enough for any client to discover and -// authenticate. The file arbitrates ownership after concurrent starts; it is -// not a startup lock: the atomic rename elects the latest writer, the watcher -// self-evicts losers, and the finalizer id-guard keeps an exiting server from -// deleting its successor's registration. -// Written and read through Service.Info so the file the server registers is -// provably the contract clients discover with. -const infoJson = Schema.fromJsonString(Service.Info) -const encodeInfo = Schema.encodeEffect(infoJson) -const decodeInfo = Schema.decodeUnknownEffect(infoJson) - -const register = Effect.fnUntraced(function* (address: HttpServer.Address) { - const fs = yield* FileSystem.FileSystem - const { file } = yield* ServiceConfig.options() - const id = randomUUID() - const secret = yield* ServiceConfig.password() - const temp = file + "." + id + ".tmp" - yield* fs.makeDirectory(path.dirname(file), { recursive: true }) - const encoded = yield* encodeInfo({ - id, - version: InstallationVersion, - url: HttpServer.formatAddress(address), - pid: process.pid, - password: secret, - }) - yield* fs.writeFileString(temp, encoded, { mode: 0o600 }) - yield* fs.rename(temp, file) - const currentID = fs.readFileString(file).pipe( - Effect.flatMap(decodeInfo), - Effect.map((info) => info.id), - Effect.orElseSucceed(() => undefined), - ) - yield* currentID.pipe( - Effect.flatMap((current) => - current === id - ? Effect.void - : Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore), - ), - Effect.repeat(Schedule.spaced("10 seconds")), - Effect.forkScoped, - ) - yield* Effect.addFinalizer(() => - currentID.pipe( - Effect.flatMap((current) => (current === id ? fs.remove(file) : Effect.void)), - Effect.ignore, - ), - ) -}) - -function waitForStdinClose() { - return Effect.callback((resume) => { - const close = () => resume(Effect.void) - process.stdin.once("end", close) - process.stdin.once("close", close) - process.stdin.resume() - if (process.stdin.readableEnded || process.stdin.destroyed) close() - return Effect.sync(() => { - process.stdin.off("end", close) - process.stdin.off("close", close) - process.stdin.pause() - }) - }) -} - -function listen(hostname: string, port: Option.Option, password: string) { - if (Option.isSome(port)) return bind(hostname, port.value, password) - const next = (port: number): ReturnType => - 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) { - const server = createServer() - return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( - Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), - Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), - ), - ).pipe( - Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), - Effect.map((context) => Context.get(context, HttpServer.HttpServer).address), - ) -} diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts new file mode 100644 index 00000000000..fff1213f81f --- /dev/null +++ b/packages/cli/src/daemon.ts @@ -0,0 +1,70 @@ +import { Service } from "@opencode-ai/client/effect" +import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ServerAuth } from "@opencode-ai/server/auth" +import { Effect } from "effect" +import { ServiceConfig } from "./services/service-config" + +export type SharedOptions = { + readonly mode: "shared" + readonly command?: ReadonlyArray +} +export type AttachOptions = { + readonly mode: "attach" + readonly url: string + readonly username?: string + readonly password?: string +} +export type Options = SharedOptions | AttachOptions + +const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) { + const transport = { + url: options.url, + headers: + options.password === undefined + ? undefined + : ServerAuth.headers({ password: options.password, username: options.username }), + } satisfies Service.Transport + const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) + const health = yield* Effect.tryPromise({ + try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), + catch: (cause) => attachError(options, cause), + }) + if (health.version !== InstallationVersion) + return yield* Effect.fail( + new Error(`Server at ${options.url} has version ${health.version}; this client requires ${InstallationVersion}`), + ) + return transport +}) + +const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) { + const config = yield* ServiceConfig.options() + const service = options.command === undefined ? config : { ...config, command: options.command } + const found = yield* Service.discover(service) + if (found) return found + return yield* Service.start(service) +}) + +export function transport(options: AttachOptions): ReturnType +export function transport(options: SharedOptions): ReturnType +export function transport(options: Options): ReturnType | ReturnType +export function transport(options: Options) { + if (options.mode === "attach") return attach(options) + return shared(options) +} + +function attachError(options: AttachOptions, cause: unknown) { + if (isUnauthorizedError(cause)) { + return new Error( + options.password === undefined + ? `Server at ${options.url} requires authentication; provide a password` + : `Server at ${options.url} rejected the supplied credentials`, + { cause }, + ) + } + if (cause instanceof ClientError && cause.reason === "Transport") + return new Error(`Could not reach server at ${options.url}`, { cause }) + return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause }) +} + +export * as Daemon from "./daemon" diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 64116b9b763..f8775fdb164 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, { logout: () => import("./commands/handlers/mcp/logout"), }, migrate: () => import("./commands/handlers/migrate"), + mini: () => import("./commands/handlers/mini"), + run: () => import("./commands/handlers/run"), service: { start: () => import("./commands/handlers/service/start"), restart: () => import("./commands/handlers/service/restart"), diff --git a/packages/opencode/src/cli/cmd/run/catalog.shared.ts b/packages/cli/src/mini/catalog.shared.ts similarity index 62% rename from packages/opencode/src/cli/cmd/run/catalog.shared.ts rename to packages/cli/src/mini/catalog.shared.ts index 826b87656b2..1af0f51f629 100644 --- a/packages/opencode/src/cli/cmd/run/catalog.shared.ts +++ b/packages/cli/src/mini/catalog.shared.ts @@ -1,13 +1,18 @@ -import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { + AgentListOutput, + CommandListOutput, + ModelListOutput, + OpenCodeClient, + ProviderListOutput, + SkillListOutput, +} from "@opencode-ai/client/promise" import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types" -type CurrentAgent = NonNullable>["data"]>["data"][number] -type CurrentCommand = NonNullable>["data"]>["data"][number] -type CurrentSkill = NonNullable>["data"]>["data"][number] -type CurrentProvider = NonNullable< - Awaited>["data"] ->["data"][number] -type CurrentModel = NonNullable>["data"]>["data"][number] +type CurrentAgent = AgentListOutput["data"][number] +type CurrentCommand = CommandListOutput["data"][number] +type CurrentSkill = SkillListOutput["data"][number] +type CurrentProvider = ProviderListOutput["data"][number] +type CurrentModel = ModelListOutput["data"][number] function location(directory: string) { return { @@ -91,16 +96,16 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ // For explicit --model flows, wait for that exact ref to appear before prompt // admission. On timeout, return and let the real execution error surface. export async function waitForCatalogReady(input: { - sdk: OpencodeClient + sdk: OpenCodeClient directory: string model: { providerID: string; modelID: string } timeoutMs?: number }) { const deadline = Date.now() + (input.timeoutMs ?? 5_000) while (Date.now() < deadline) { - const models = await input.sdk.v2.model - .list(location(input.directory), { throwOnError: true }) - .then((result) => result.data?.data ?? []) + const models = await input.sdk.model + .list(location(input.directory)) + .then((result) => result.data) .catch(() => undefined) if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return await new Promise((resolve) => setTimeout(resolve, 25)) @@ -108,47 +113,47 @@ export async function waitForCatalogReady(input: { } export async function waitForDefaultModel(input: { - sdk: OpencodeClient + sdk: OpenCodeClient directory: string timeoutMs?: number active?: () => boolean }): Promise<{ providerID: string; modelID: string } | undefined> { const deadline = Date.now() + (input.timeoutMs ?? 5_000) while (Date.now() < deadline && (input.active?.() ?? true)) { - const model = await input.sdk.v2.model - .default(location(input.directory), { throwOnError: true }) - .then((result) => result.data?.data) + const model = await input.sdk.model + .default(location(input.directory)) + .then((result) => result.data) .catch(() => undefined) if (model) return { providerID: model.providerID, modelID: model.id } await new Promise((resolve) => setTimeout(resolve, 25)) } } -export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise { - const result = await sdk.v2.agent.list(location(directory), { throwOnError: true }) - return (result.data?.data ?? []).map(runAgent) +export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise { + const result = await sdk.agent.list(location(directory)) + return result.data.map(runAgent) } -export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise { +export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise { const [commands, skills] = await Promise.all([ - sdk.v2.command.list(location(directory), { throwOnError: true }), - sdk.v2.skill.list(location(directory), { throwOnError: true }), + sdk.command.list(location(directory)), + sdk.skill.list(location(directory)), ]) return [ - ...(commands.data?.data ?? []).map(runCommand), - ...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill), + ...commands.data.map(runCommand), + ...skills.data.filter((skill) => skill.slash !== false).map(runSkill), ] } -export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise { - const result = await sdk.v2.reference.list(location(directory), { throwOnError: true }) - return (result.data?.data ?? []).filter((reference) => !reference.hidden) +export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise { + const result = await sdk.reference.list(location(directory)) + return result.data.filter((reference) => !reference.hidden) } -export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise { +export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise { const [providers, models] = await Promise.all([ - sdk.v2.provider.list(location(directory), { throwOnError: true }), - sdk.v2.model.list(location(directory), { throwOnError: true }), + sdk.provider.list(location(directory)), + sdk.model.list(location(directory)), ]) - return runProviders(providers.data?.data ?? [], models.data?.data ?? []) + return runProviders([...providers.data], [...models.data]) } diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/cli/src/mini/demo.ts similarity index 99% rename from packages/opencode/src/cli/cmd/run/demo.ts rename to packages/cli/src/mini/demo.ts index 94450f12425..b06b3c45370 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/cli/src/mini/demo.ts @@ -678,7 +678,7 @@ function emitTask(state: State): void { state: { status: "running", input: { - filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + filePath: "packages/cli/src/mini/stream.ts", offset: 1, limit: 200, }, diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/cli/src/mini/entry.body.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/entry.body.ts rename to packages/cli/src/mini/entry.body.ts diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/cli/src/mini/footer.command.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.command.tsx rename to packages/cli/src/mini/footer.command.tsx diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/cli/src/mini/footer.menu.tsx similarity index 99% rename from packages/opencode/src/cli/cmd/run/footer.menu.tsx rename to packages/cli/src/mini/footer.menu.tsx index 7e820cc6a73..ab9bea05ea7 100644 --- a/packages/opencode/src/cli/cmd/run/footer.menu.tsx +++ b/packages/cli/src/mini/footer.menu.tsx @@ -3,7 +3,7 @@ import { TextAttributes, type ColorInput } from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { transparent, type RunFooterTheme } from "./theme" -import * as Locale from "@/util/locale" +import { Locale } from "@opencode-ai/tui/util/locale" export const FOOTER_MENU_ROWS = 8 diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/cli/src/mini/footer.permission.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.permission.tsx rename to packages/cli/src/mini/footer.permission.tsx diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx similarity index 99% rename from packages/opencode/src/cli/cmd/run/footer.prompt.tsx rename to packages/cli/src/mini/footer.prompt.tsx index 224a707dd2e..6ba7940ab6c 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -12,7 +12,7 @@ import { normalizePromptContent } from "@opencode-ai/tui/prompt/content" import fuzzysort from "fuzzysort" import path from "path" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" -import * as Locale from "@/util/locale" +import { Locale } from "@opencode-ai/tui/util/locale" import { createPromptHistory, displayCharAt, @@ -67,7 +67,7 @@ type PromptInput = { prompt: Accessor width: Accessor theme: Accessor - history?: RunPrompt[] + history?: Accessor onSubmit: (input: RunPrompt) => boolean | Promise onCycle: () => void onInterrupt: () => boolean @@ -302,7 +302,10 @@ export function createPromptState(input: PromptInput): PromptState { return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')]) }) - let history = createPromptHistory(input.history) + let history = createPromptHistory(input.history?.()) + createEffect(() => { + history = createPromptHistory(input.history?.()) + }) let draft: RunPrompt = { text: "", parts: [] } let stash: RunPrompt = { text: "", parts: [] } let area: TextareaRenderable | undefined diff --git a/packages/opencode/src/cli/cmd/run/footer.question.tsx b/packages/cli/src/mini/footer.question.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.question.tsx rename to packages/cli/src/mini/footer.question.tsx diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/cli/src/mini/footer.subagent.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.subagent.tsx rename to packages/cli/src/mini/footer.subagent.tsx diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/cli/src/mini/footer.ts similarity index 98% rename from packages/opencode/src/cli/cmd/run/footer.ts rename to packages/cli/src/mini/footer.ts index 2432c29689b..ab53e9162e6 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/cli/src/mini/footer.ts @@ -203,6 +203,8 @@ export class RunFooter implements FooterApi { private setSubagent: (next: FooterSubagentState) => void private queuedPrompts: Accessor private setQueuedPrompts: Setter + private history: Accessor + private setHistory: Setter private promptRoute: FooterPromptRoute = { type: "composer" } private subagentMenuRows = SUBAGENT_ROWS private autocomplete = false @@ -289,6 +291,9 @@ export class RunFooter implements FooterApi { const [queuedPrompts, setQueuedPrompts] = createSignal([]) this.queuedPrompts = queuedPrompts this.setQueuedPrompts = setQueuedPrompts + const [history, setHistory] = createSignal(options.history ?? []) + this.history = history + this.setHistory = setHistory this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS) this.scrollback = this.createScrollback(options.wrote ?? false) @@ -322,7 +327,7 @@ export class RunFooter implements FooterApi { diffStyle: options.diffStyle, tuiConfig: options.tuiConfig, backgroundSubagents: options.backgroundSubagents, - history: options.history, + history: footer.history, agent: options.agentLabel, onSubmit: footer.handlePrompt, onPermissionReply: footer.handlePermissionReply, @@ -390,6 +395,11 @@ export class RunFooter implements FooterApi { } public event(next: FooterEvent): void { + if (next.type === "history") { + this.setHistory(next.history) + return + } + if (next.type === "model") { this.setCurrentModel(next.selection) } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/cli/src/mini/footer.view.tsx similarity index 99% rename from packages/opencode/src/cli/cmd/run/footer.view.tsx rename to packages/cli/src/mini/footer.view.tsx index a7c13214941..b8f50659ba3 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/cli/src/mini/footer.view.tsx @@ -88,7 +88,7 @@ type RunFooterViewProps = { diffStyle?: RunDiffStyle tuiConfig: RunTuiConfig backgroundSubagents: boolean - history?: RunPrompt[] + history?: () => RunPrompt[] agent: string onSubmit: (input: RunPrompt) => boolean onPermissionReply: (input: PermissionReply) => void | Promise diff --git a/packages/opencode/src/cli/cmd/run/footer.width.ts b/packages/cli/src/mini/footer.width.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/footer.width.ts rename to packages/cli/src/mini/footer.width.ts diff --git a/packages/cli/src/mini/index.ts b/packages/cli/src/mini/index.ts new file mode 100644 index 00000000000..62decb77a56 --- /dev/null +++ b/packages/cli/src/mini/index.ts @@ -0,0 +1,7 @@ +export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini" +export { + runNonInteractive, + mergeInput as mergeNonInteractiveInput, + pickRunModel, + type RunCommandInput, +} from "./run" diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts new file mode 100644 index 00000000000..9259bd5dbee --- /dev/null +++ b/packages/cli/src/mini/mini.ts @@ -0,0 +1,234 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import { truthy } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Effect } from "effect" +import path from "node:path" +import { Daemon } from "../daemon" +import { waitForCatalogReady } from "./catalog.shared" +import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" +import type { RunInput, RunTuiConfig } from "./types" + +export type MiniCommandInput = { + directory?: string + attach?: string + password?: string + username?: string + continue?: boolean + session?: string + fork?: boolean + model?: string + agent?: string + prompt?: string + replay?: boolean + replayLimit?: number + demo?: boolean + serverCommand?: ReadonlyArray + tuiConfig?: RunTuiConfig | Promise +} + +type Session = Awaited> +type Transport = { readonly url: string; readonly headers?: HeadersInit } + +export async function runMini(input: MiniCommandInput) { + validate(input) + const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt) + const runtimeTask = import("./runtime") + const directory = input.attach ? input.directory : localDirectory(input.directory) + const transportTask = startTransport(input) + void transportTask.catch(() => {}) + + try { + if (input.attach) await transportTask + const sdk = OpenCode.make({ + baseUrl: "http://opencode.pending", + fetch: deferredFetch(transportTask), + }) + const attachedSession = + input.attach && input.session && !input.directory + ? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found")) + : undefined + const resolvedDirectory = + directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk)) + const model = parseModel(input.model) + let agentTask: Promise | undefined + const resolveAgent = () => { + agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach) + return agentTask + } + const resolveSession = async () => { + const [agent, selected] = await Promise.all([ + resolveAgent(), + selectSession(sdk, resolvedDirectory, input, attachedSession), + ]) + const readyModel = + model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined) + if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel }) + const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model)) + return { id: session.id, title: session.title, resume: selected !== undefined } + } + const create = ( + _sdk: OpenCodeClient, + next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined }, + ) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant) + const runtime = await runtimeTask + await runtime.runInteractiveDeferredMode({ + sdk, + directory: resolvedDirectory, + resolveAgent, + session: resolveSession, + createSession: create, + agent: input.agent, + model, + variant: undefined, + files: [], + initialInput, + thinking: true, + backgroundSubagents: + truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") || + (process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")), + replay: input.replay ?? true, + replayLimit: input.replayLimit, + demo: input.demo, + tuiConfig: input.tuiConfig, + }) + } catch (error) { + if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message) + throw error + } +} + +/** @internal Exported for testing. */ +export function mergeInput(piped: string | undefined, prompt: string | undefined) { + if (!prompt) return piped || undefined + if (!piped) return prompt + return piped + "\n" + prompt +} + +function validate(input: MiniCommandInput) { + if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout") + if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) { + fail("--replay-limit must be a positive integer") + } + if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session") + resolveInteractiveStdin().cleanup?.() +} + +function localDirectory(directory?: string): string { + const root = process.env.PWD ?? process.cwd() + try { + process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root) + return process.cwd() + } catch { + fail(`Failed to change directory to ${directory}`) + } +} + +function startTransport(input: MiniCommandInput): Promise { + if (input.attach) { + return Effect.runPromise( + Daemon.transport({ + mode: "attach", + url: input.attach, + password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD, + username: input.username ?? process.env.OPENCODE_SERVER_USERNAME, + }), + ) + } + return Effect.runPromise( + Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe( + Effect.provide(NodeFileSystem.layer), + Effect.provide(Global.layerWith({})), + ), + ) +} + +function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch { + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const transport = await transportTask + const request = new Request(input, init) + const source = new URL(request.url) + const headers = new Headers(request.headers) + for (const [key, value] of new Headers(transport.headers)) headers.set(key, value) + return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers }) + } + return fetch as typeof globalThis.fetch +} + +async function remoteDirectory( + transport: { url: string; headers?: HeadersInit }, + sdk: OpenCodeClient, +): Promise { + const location = await sdk.location.get() + if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`) + return location.directory +} + +function parseModel(value?: string): RunInput["model"] { + if (!value) return + const [providerID, ...rest] = value.split("/") + const modelID = rest.join("/") + if (!providerID || !modelID) fail("--model must use the format provider/model") + return { providerID, modelID } +} + +async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) { + if (!name) return + const deadline = Date.now() + 5_000 + let agents: Awaited> | undefined + while (Date.now() < deadline) { + agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined) + const agent = agents?.data.find((item) => item.id === name) + if (agent?.mode === "subagent") { + warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`) + return + } + if (agent) return name + await Bun.sleep(25) + } + if (!agents) { + warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`) + return + } + warning(`agent "${name}" not found. Falling back to default agent`) +} + +async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) { + const selected = + preselected ?? + (input.session + ? await sdk.session.get({ sessionID: input.session }).catch(() => undefined) + : input.continue + ? await sdk.session + .list({ directory, parentID: null, limit: 1, order: "desc" }) + .then((result) => result.data[0]) + : undefined) + if (input.session && !selected) fail("Session not found") + if (!selected) return + if (!input.fork) return selected + return sdk.session.fork({ sessionID: selected.id }) +} + +async function createSession( + sdk: OpenCodeClient, + directory: string, + agent: string | undefined, + model: RunInput["model"], + variant?: string, +): Promise { + if (model) await waitForCatalogReady({ sdk, directory, model }) + return sdk.session.create({ + agent, + model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined, + location: { directory }, + }) +} + +function warning(message: string) { + process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`) +} + +function fail(message: string): never { + process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`) + process.exit(1) +} diff --git a/packages/opencode/src/cli/cmd/run/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts similarity index 85% rename from packages/opencode/src/cli/cmd/run/noninteractive.ts rename to packages/cli/src/mini/noninteractive.ts index 68745272224..0645daac51e 100644 --- a/packages/opencode/src/cli/cmd/run/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -1,15 +1,17 @@ import type { - OpencodeClient, + EventSubscribeOutput, + OpenCodeClient, +} from "@opencode-ai/client/promise" +import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart, - V2Event, } from "@opencode-ai/sdk/v2" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { EOL } from "node:os" -import { MessageID } from "@/session/schema" -import { UI } from "../../ui" +import { UI } from "./ui" type Model = { providerID: string @@ -23,7 +25,7 @@ type File = { } type Input = { - client: OpencodeClient + client: OpenCodeClient sessionID: string message: string files: File[] @@ -52,6 +54,7 @@ type ToolState = StartedPart & { provider?: unknown } +type V2Event = EventSubscribeOutput type FormRequest = Extract["data"]["form"] // MCP elicitations are temporarily owned by the "global" sentinel instead of a real @@ -61,16 +64,11 @@ const GLOBAL_FORM_SESSION_ID = "global" export async function runNonInteractivePrompt(input: Input) { const controller = new AbortController() - const events = await input.client.v2.event.subscribe({ - signal: controller.signal, - sseMaxRetryAttempts: 0, - throwOnError: true, - }) - const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator + const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]() const connected = await stream.next() if (connected.done) throw new Error("Event stream disconnected before prompt admission") - const messageID = MessageID.ascending() + const messageID = SessionMessage.ID.create() const starts = new Map() const tools = new Map() let submitted = false @@ -101,7 +99,7 @@ export async function runNonInteractivePrompt(input: Input) { UI.empty() } - const replyPermission = async (request: { id: string; action: string; resources: string[] }) => { + const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray }) => { if (!input.dangerouslySkipPermissions) { permissionRejected = true UI.println( @@ -110,7 +108,7 @@ export async function runNonInteractivePrompt(input: Input) { `permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`, ) } - await input.client.v2.session.permission + await input.client.permission .reply({ sessionID: input.sessionID, requestID: request.id, @@ -118,18 +116,18 @@ export async function runNonInteractivePrompt(input: Input) { }) .catch(() => {}) if (!input.dangerouslySkipPermissions) { - await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } } const rejectQuestion = async (request: { id: string }) => { questionRejected = true - await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {}) + await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {}) } const cancelForm = async (request: Pick) => { formCancelled = true - await input.client.v2.session.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {}) + await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {}) } const consume = async () => { @@ -156,7 +154,7 @@ export async function runNonInteractivePrompt(input: Input) { continue } if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue - const time = toMillis(event.created) + const time = toMillis("created" in event ? event.created : undefined) if (event.type === "session.prompt.promoted") { if (event.data.inputID === messageID) { @@ -367,34 +365,31 @@ export async function runNonInteractivePrompt(input: Input) { interrupted = true process.exitCode = 130 admission?.abort() - void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + void input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } process.on("SIGINT", interrupt) let completed: Promise | undefined try { if (input.agent) { - await input.client.v2.session.switchAgent( - { sessionID: input.sessionID, agent: input.agent }, - { throwOnError: true }, - ) + await input.client.session.switchAgent({ sessionID: input.sessionID, agent: input.agent }) } const selected = input.model ? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant } : input.variant - ? await input.client.v2.session - .get({ sessionID: input.sessionID }, { throwOnError: true }) - .then((result) => result.data.data.model) + ? await input.client.session + .get({ sessionID: input.sessionID }) + .then((result) => result.model) .then(async (model) => { if (model) return { ...model, variant: input.variant } - const result = await input.client.v2.model.default(undefined, { throwOnError: true }) - const fallback = result.data.data + const result = await input.client.model.default() + const fallback = result.data return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined }) : undefined if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model") if (selected) { - await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true }) + await input.client.session.switchModel({ sessionID: input.sessionID, model: selected }) } const prepared = await Promise.all(input.files.map(prepareFile)) @@ -402,7 +397,7 @@ export async function runNonInteractivePrompt(input: Input) { submitted = true completed = consume() admission = new AbortController() - const response = await input.client.v2.session + const response = await input.client.session .prompt( { sessionID: input.sessionID, @@ -413,11 +408,11 @@ export async function runNonInteractivePrompt(input: Input) { }, delivery: "steer", }, - { throwOnError: true, signal: admission.signal }, + { signal: admission.signal }, ) .catch(async (error) => { if (interrupted) { - await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } controller.abort() await completed?.catch(() => {}) @@ -426,22 +421,21 @@ export async function runNonInteractivePrompt(input: Input) { }) admission = undefined if (!response) return - if (!response.data.data) throw new Error("Prompt was not admitted") - if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) const [permissions, questions, forms] = await Promise.all([ - input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined), - input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined), + input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined), + input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined), Promise.all( (input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) => - input.client.v2.session.form.list({ sessionID }).catch(() => undefined), + input.client.form.list({ sessionID }).catch(() => undefined), ), ), ]) await Promise.all([ - ...(permissions?.data?.data ?? []).map(replyPermission), - ...(questions?.data?.data ?? []).map(rejectQuestion), - ...forms.flatMap((response) => response?.data?.data ?? []).map(cancelForm), + ...(permissions ?? []).map(replyPermission), + ...(questions ?? []).map(rejectQuestion), + ...forms.flatMap((response) => response ?? []).map(cancelForm), ]) await completed } finally { diff --git a/packages/opencode/src/cli/cmd/run/permission.shared.ts b/packages/cli/src/mini/permission.shared.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/permission.shared.ts rename to packages/cli/src/mini/permission.shared.ts diff --git a/packages/opencode/src/cli/cmd/run/prompt.editor.ts b/packages/cli/src/mini/prompt.editor.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/prompt.editor.ts rename to packages/cli/src/mini/prompt.editor.ts diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/cli/src/mini/prompt.shared.ts similarity index 99% rename from packages/opencode/src/cli/cmd/run/prompt.shared.ts rename to packages/cli/src/mini/prompt.shared.ts index 63c33aa34ff..56d1cb29681 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/cli/src/mini/prompt.shared.ts @@ -7,7 +7,7 @@ // the current browse position. When the user arrows up at cursor offset 0, // the current draft is saved and history begins. Arrowing past the end // restores the draft. -export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display" +export { displayCharAt, displaySlice, mentionTriggerIndex } from "@opencode-ai/tui/prompt/display" import type { RunPrompt } from "./types" const HISTORY_LIMIT = 200 diff --git a/packages/opencode/src/cli/cmd/run/question.shared.ts b/packages/cli/src/mini/question.shared.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/question.shared.ts rename to packages/cli/src/mini/question.shared.ts diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts new file mode 100644 index 00000000000..ee4fc1cb539 --- /dev/null +++ b/packages/cli/src/mini/run.ts @@ -0,0 +1,308 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import { Global } from "@opencode-ai/core/global" +import { FSUtil } from "@opencode-ai/core/fs-util" +import type { ToolPart } from "@opencode-ai/sdk/v2" +import { Effect } from "effect" +import { open } from "node:fs/promises" +import path from "node:path" +import { Daemon } from "../daemon" +import { Standalone } from "../services/standalone" +import { loadRunAgents, waitForCatalogReady, waitForDefaultModel } from "./catalog.shared" +import { runNonInteractivePrompt } from "./noninteractive" +import { toolInlineInfo } from "./tool" +import { UI } from "./ui" + +export type RunCommandInput = { + message: string[] + continue?: boolean + session?: string + fork?: boolean + model?: string + agent?: string + format: "default" | "json" + file: string[] + title?: string + server?: string + password?: string + username?: string + directory?: string + variant?: string + thinking?: boolean + dangerouslySkipPermissions?: boolean + standaloneCommand?: ReadonlyArray +} + +type FilePart = { + url: string + filename: string + mime: string +} + +type Transport = { readonly url: string; readonly headers?: HeadersInit } + +type Prepared = { + directory?: string + message: string + files: FilePart[] +} + +const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 + +export async function runNonInteractive(input: RunCommandInput) { + if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session") + const root = process.env.PWD ?? process.cwd() + const directory = input.server ? input.directory : localDirectory(input.directory, root) + const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text()) + if (!message?.trim()) fail("You must provide a message") + const files = await Promise.all( + input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)), + ) + const prepared = { directory, message, files } + if (input.standaloneCommand) + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const transport = yield* Standalone.transport({ command: input.standaloneCommand }) + yield* Effect.promise(() => execute(input, prepared, transport)) + }), + ), + ) + const transport = await startTransport(input) + return execute(input, prepared, transport) +} + +async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) { + const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) + const requestedDirectory = prepared.directory ?? (await client.location.get()).directory + if (!requestedDirectory) fail("Failed to resolve server directory") + const session = await selectSession(client, requestedDirectory, input) + const cwd = session?.location.directory ?? requestedDirectory + const explicitModel = parseModel(input.model) + const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined + const defaultModel = + input.variant && !explicitModel && !sessionModel + ? await waitForDefaultModel({ sdk: client, directory: cwd }) + : undefined + const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel) + if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) + if (model) { + await waitForCatalogReady({ sdk: client, directory: cwd, model }) + const available = await client.model.list({ location: { directory: cwd } }) + if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID)) + return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id) + } + const agent = await validateAgent(client, cwd, input.agent, input.server) + const selected = + session ?? + (await client.session.create({ + agent, + model: model ? { providerID: model.providerID, id: model.modelID, variant: input.variant } : undefined, + location: { directory: cwd }, + })) + if (!session && input.title !== undefined) { + await client.session.rename({ + sessionID: selected.id, + title: + input.title || + prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), + }) + } + + try { + await runNonInteractivePrompt({ + client, + sessionID: selected.id, + message: prepared.message, + files: prepared.files, + agent, + model, + variant: input.variant, + thinking: input.thinking ?? false, + format: input.format, + dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false, + attached: !input.standaloneCommand, + renderTool, + renderToolError, + }) + } catch (error) { + UI.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } +} + +export function mergeInput(message: string | undefined, piped: string | undefined) { + if (!message) return piped || undefined + if (!piped) return message + return message + "\n" + piped +} + +export function pickRunModel( + explicit: { providerID: string; modelID: string } | undefined, + variant: string | undefined, + session: { providerID: string; modelID: string } | undefined, + fallback: { providerID: string; modelID: string } | undefined, +) { + if (explicit) return explicit + if (!variant) return + return session ?? fallback +} + +function formatMessage(message: string[]) { + const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ") + return value || undefined +} + +function localDirectory(directory: string | undefined, root: string) { + try { + process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root) + return process.cwd() + } catch { + fail(`Failed to change directory to ${directory}`) + } +} + +function startTransport(input: RunCommandInput) { + if (input.server) { + return Effect.runPromise( + Daemon.transport({ + mode: "attach", + url: input.server, + password: input.password, + username: input.username, + }), + ) + } + return Effect.runPromise( + Daemon.transport({ mode: "shared" }).pipe( + Effect.provide(NodeFileSystem.layer), + Effect.provide(Global.layerWith({})), + ), + ) +} + +function parseModel(value?: string) { + if (!value) return + const [providerID, ...rest] = value.split("/") + const modelID = rest.join("/") + if (!providerID || !modelID) fail("--model must use the format provider/model") + return { providerID, modelID } +} + +async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) { + if (!name) return + const agents = await loadRunAgents(client, directory).catch(() => undefined) + if (!agents) { + warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`) + return + } + const agent = agents.find((item) => item.name === name) + if (!agent) { + warning(`agent "${name}" not found. Falling back to default agent`) + return + } + if (agent.mode === "subagent") { + warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`) + return + } + return name +} + +async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) { + const selected = input.session + ? await client.session.get({ sessionID: input.session }).catch(() => undefined) + : input.continue + ? await client.session + .list({ directory, parentID: null, limit: 1, order: "desc" }) + .then((result) => result.data[0]) + : undefined + if (input.session && !selected) fail("Session not found") + if (!selected || !input.fork) return selected + return client.session.fork({ sessionID: selected.id }) +} + +async function prepareFile(input: string, directory: string, remote: boolean): Promise { + const file = path.resolve(directory, input) + const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`)) + try { + const stat = await handle.stat() + if (remote && stat.isDirectory()) fail(`Cannot attach local directory without a shared filesystem: ${input}`) + if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES) + fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`) + const content = Buffer.alloc(Number(stat.size)) + let offset = 0 + while (offset < content.length) { + const read = await handle.read(content, offset, content.length - offset, offset) + if (read.bytesRead === 0) break + offset += read.bytesRead + } + const bytes = content.subarray(0, offset) + const detected = FSUtil.mimeType(file) + const text = bytes.toString("utf8") + const mime = + detected.startsWith("image/") || detected === "application/pdf" + ? detected + : !isBinaryContent(bytes) && Buffer.from(text, "utf8").equals(bytes) + ? "text/plain" + : detected + return { + url: `data:${mime};base64,${bytes.toString("base64")}`, + filename: path.basename(file), + mime, + } + } finally { + await handle.close() + } +} + +function isBinaryContent(bytes: Uint8Array) { + if (bytes.length === 0) return false + if (bytes.includes(0)) return true + return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3 +} + +async function renderTool(part: ToolPart) { + const info = toolInlineInfo(part) + if (info.mode === "block") { + UI.empty() + UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title) + if (info.body?.trim()) UI.println(info.body) + UI.empty() + return + } + UI.println( + UI.Style.TEXT_NORMAL + info.icon, + UI.Style.TEXT_NORMAL + info.title, + info.description ? UI.Style.TEXT_DIM + info.description + UI.Style.TEXT_NORMAL : "", + ) +} + +async function renderToolError(part: ToolPart) { + const info = toolInlineInfo(part) + UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`) +} + +function warning(message: string) { + UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message) +} + +function reportError(input: RunCommandInput, message: string, sessionID?: string) { + process.exitCode = 1 + if (input.format === "json") { + process.stdout.write( + JSON.stringify({ + type: "error", + timestamp: Date.now(), + sessionID: sessionID ?? "", + error: { type: "unknown", message }, + }) + "\n", + ) + return + } + UI.error(message) +} + +function fail(message: string): never { + UI.error(message) + process.exit(1) +} diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/cli/src/mini/runtime.boot.ts similarity index 75% rename from packages/opencode/src/cli/cmd/run/runtime.boot.ts rename to packages/cli/src/mini/runtime.boot.ts index eae1db77e8a..b5fca98dc09 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/cli/src/mini/runtime.boot.ts @@ -7,12 +7,10 @@ // none block each other. import { Context, Effect, Layer } from "effect" import { resolve } from "@opencode-ai/tui/config" -import { TuiConfig } from "@/config/tui" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" -import { makeRuntime } from "@/effect/run-service" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { loadRunProviders } from "./catalog.shared" -import { reusePendingTask } from "./runtime.shared" import { resolveCurrentSession, sessionHistory } from "./session.shared" import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" import { pickVariant } from "./variant.shared" @@ -30,7 +28,6 @@ export type SessionInfo = { variant: string | undefined } -type Config = Awaited> type BootService = { readonly resolveModelInfo: ( sdk: RunInput["sdk"], @@ -42,18 +39,10 @@ type BootService = { sessionID: string, model: RunInput["model"], ) => Effect.Effect - readonly resolveRunTuiConfig: () => Effect.Effect - readonly resolveDiffStyle: () => Effect.Effect } -const configTask: { current?: Promise } = {} - class Service extends Context.Service()("@opencode/RunBoot") {} -function loadConfig() { - return reusePendingTask(configTask, () => TuiConfig.get()) -} - function emptyModelInfo(): ModelInfo { return { providers: [], @@ -77,23 +66,9 @@ function defaultRunTuiConfig(): RunTuiConfig { } } -function runTuiConfig(config: Config | undefined): RunTuiConfig { - if (!config) { - return defaultRunTuiConfig() - } - - return { - keybinds: config.keybinds, - leader_timeout: config.leader_timeout, - diff_style: config.diff_style ?? "auto", - } -} - const layer = Layer.effect( Service, Effect.gen(function* () { - const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined))) - const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* ( sdk: RunInput["sdk"], directory: string, @@ -147,19 +122,9 @@ const layer = Layer.effect( } }) - const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () { - return runTuiConfig(yield* config()) - }) - - const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () { - return runTuiConfig(yield* config()).diff_style ?? "auto" - }) - return Service.of({ resolveModelInfo, resolveSessionInfo, - resolveRunTuiConfig, - resolveDiffStyle, }) }), ) @@ -190,10 +155,12 @@ export async function resolveSessionInfo( } // Reads TUI config once for direct mode keymap setup and display preferences. -export async function resolveRunTuiConfig(): Promise { - return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig()) +export async function resolveRunTuiConfig( + config?: RunTuiConfig | Promise, +): Promise { + return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig()) } -export async function resolveDiffStyle(): Promise { - return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto") +export async function resolveDiffStyle(config?: RunTuiConfig | Promise): Promise { + return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto") } diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/cli/src/mini/runtime.lifecycle.ts similarity index 99% rename from packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts rename to packages/cli/src/mini/runtime.lifecycle.ts index e06523c7c6a..4700f7c479e 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/cli/src/mini/runtime.lifecycle.ts @@ -13,8 +13,8 @@ import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWr import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Global } from "@opencode-ai/core/global" import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap" -import { isDefaultTitle } from "@/session/title" -import * as Locale from "@/util/locale" +import { isDefaultTitle } from "@opencode-ai/tui/util/session" +import { Locale } from "@opencode-ai/tui/util/locale" import { resolveInteractiveStdin } from "./runtime.stdin" import { entrySplash, exitSplash, splashMeta } from "./splash" import { resolveRunTheme } from "./theme" diff --git a/packages/opencode/src/cli/cmd/run/runtime.queue.ts b/packages/cli/src/mini/runtime.queue.ts similarity index 96% rename from packages/opencode/src/cli/cmd/run/runtime.queue.ts rename to packages/cli/src/mini/runtime.queue.ts index d29e03672a2..5a2bdb98dbb 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.queue.ts +++ b/packages/cli/src/mini/runtime.queue.ts @@ -8,8 +8,9 @@ // and tracks per-turn wall-clock duration for the footer status line. // // Resolves when the footer closes and all in-flight work finishes. -import * as Locale from "@/util/locale" -import { MessageID, PartID } from "@/session/schema" +import { ascending } from "@opencode-ai/schema/identifier" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Locale } from "@opencode-ai/tui/util/locale" import { isExitCommand, isNewCommand } from "./prompt.shared" import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types" @@ -167,7 +168,7 @@ export async function runPromptQueue(input: QueueInput): Promise { ? prompt : { ...prompt, - messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(), + messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(), } state.active = sent @@ -285,8 +286,8 @@ export async function runPromptQueue(input: QueueInput): Promise { !isNewCommand(prompt.text) ) { const queued: FooterQueuedPrompt = { - messageID: MessageID.ascending(), - partID: PartID.ascending(), + messageID: SessionMessage.ID.create(), + partID: "prt_" + ascending(), prompt, } state.queued = [...state.queued, queued] diff --git a/packages/opencode/src/cli/cmd/run/runtime.shared.ts b/packages/cli/src/mini/runtime.shared.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/runtime.shared.ts rename to packages/cli/src/mini/runtime.shared.ts diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/cli/src/mini/runtime.stdin.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/runtime.stdin.ts rename to packages/cli/src/mini/runtime.stdin.ts diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/cli/src/mini/runtime.ts similarity index 86% rename from packages/opencode/src/cli/cmd/run/runtime.ts rename to packages/cli/src/mini/runtime.ts index 601cdc567f5..8f575a87fe7 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/cli/src/mini/runtime.ts @@ -4,7 +4,7 @@ // and prompt queue together into a single session loop. Two entry points: // // runInteractiveMode -- used when an SDK client already exists (attach mode) -// runInteractiveLocalMode -- used for local in-process mode (no server) +// runInteractiveDeferredMode -- paints before resolving its session // // Both delegate to runInteractiveRuntime, which: // 1. resolves TUI config, model info, and session history, @@ -12,15 +12,22 @@ // 3. starts the stream transport (SDK event subscription), lazily for fresh // local sessions, // 4. runs the prompt queue until the footer closes. -import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" -import { MessageID } from "@/session/schema" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared" import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" import { trace } from "./trace" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" -import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types" +import type { + LocalReplayAnchor, + LocalReplayRow, + RunInput, + RunPrompt, + RunProvider, + RunTuiConfig, + StreamCommit, +} from "./types" /** @internal Exported for testing */ export { pickVariant, resolveVariant } from "./variant.shared" @@ -44,9 +51,7 @@ type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promis type RunRuntimeInput = { boot: () => Promise afterPaint?: (ctx: BootContext) => Promise | void - resolveSession?: ( - ctx: BootContext, - ) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> + resolveSession?: (ctx: BootContext) => Promise createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise files: RunInput["files"] initialInput?: string @@ -55,13 +60,14 @@ type RunRuntimeInput = { replay?: boolean replayLimit?: number demo?: RunInput["demo"] + tuiConfig?: RunTuiConfig | Promise } -type RunLocalInput = { +type RunDeferredInput = { + sdk: RunInput["sdk"] directory: string - fetch: typeof globalThis.fetch resolveAgent: () => Promise - session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined> + session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string; resume?: boolean } | undefined> createSession?: CreateSession agent: RunInput["agent"] model: RunInput["model"] @@ -73,6 +79,7 @@ type RunLocalInput = { replay?: boolean replayLimit?: number demo?: RunInput["demo"] + tuiConfig?: RunTuiConfig | Promise } type StreamTransportModule = Pick< @@ -96,6 +103,7 @@ type ResolvedSession = { sessionID: string sessionTitle?: string agent?: string | undefined + resume?: boolean } function createSessionResolver(fn?: CreateSession) { @@ -165,9 +173,9 @@ async function resolveExitTitle( return undefined } - return ctx.sdk.v2.session + return ctx.sdk.session .get({ sessionID: state.sessionID }) - .then((x) => x.data?.data.title) + .then((session) => session.title) .catch(() => undefined) } @@ -180,7 +188,7 @@ async function resolveExitTitle( async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise { const start = performance.now() const log = trace() - const tuiConfigTask = resolveRunTuiConfig() + const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig) const ctx = await input.boot() const sessionTask = ctx.resume === true @@ -247,9 +255,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({ directory: ctx.directory, findFiles: (query) => - ctx.sdk.find - .files({ query, directory: ctx.directory }) - .then((x) => x.data ?? []) + ctx.sdk.file + .find({ query, type: "file", location: { directory: ctx.directory } }) + .then((result) => result.data.map((file) => file.path)) .catch(() => []), agents: [], references: [], @@ -257,7 +265,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep sessionTitle: state.sessionTitle, getSessionID: () => state.sessionID, first: session.first, - history: session.history, + history: state.history, agent: state.agent, model: state.model, variant: state.activeVariant, @@ -269,17 +277,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } log?.write("send.permission.reply", next) - await ctx.sdk.v2.session.permission.reply({ sessionID: state.sessionID, ...next }) + await ctx.sdk.permission.reply({ sessionID: state.sessionID, ...next }) }, onQuestionReply: async (next) => { if (state.demo?.questionReply(next)) { return } - await ctx.sdk.v2.session.question.reply({ + await ctx.sdk.question.reply({ sessionID: state.sessionID, requestID: next.requestID, - questionV2Reply: { answers: next.answers ?? [] }, + answers: next.answers ?? [], }) }, onQuestionReject: async (next) => { @@ -287,7 +295,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return } - await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next }) + await ctx.sdk.question.reject({ sessionID: state.sessionID, ...next }) }, onCycleVariant: () => { if (!state.model || state.variants.length === 0) { @@ -369,7 +377,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep void ( state.stream ? state.stream.then((item) => item.handle.interruptActiveTurn()) - : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }) + : ctx.sdk.session.interrupt({ sessionID: state.sessionID }) ) .catch(() => {}) .finally(() => { @@ -383,11 +391,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } log?.write("send.background", { sessionID: state.sessionID }) - void ctx.sdk.v2.session.background({ sessionID: state.sessionID }).catch(() => {}) + void ctx.sdk.session.background({ sessionID: state.sessionID }).catch(() => {}) }, onSubagentInterrupt: (sessionID) => { log?.write("send.subagent.interrupt", { sessionID }) - void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {}) + void ctx.sdk.session.interrupt({ sessionID }).catch(() => {}) }, onSubagentSelect: (sessionID) => { state.selectSubagent?.(sessionID) @@ -398,7 +406,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) const footer = shell.footer const firstPaint = footer.idle().catch(() => {}) - const modelTask = firstPaint.then(() => (footer.isClosed ? undefined : loadModel())) const ensureSession = () => { if (!input.resolveSession || state.sessionID) { return Promise.resolve() @@ -408,13 +415,33 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return state.session } - state.session = input.resolveSession(ctx).then((next) => { + state.session = input.resolveSession(ctx).then(async (next) => { state.sessionID = next.sessionID state.sessionTitle = next.sessionTitle ?? state.sessionTitle state.agent = next.agent + if (!next.resume) return + const resumed = await resolveSessionInfo(ctx.sdk, next.sessionID, ctx.model) + session.first = resumed.first + session.history = resumed.history + session.model = resumed.model + session.variant = resumed.variant + state.shown = !resumed.first + state.history = [...resumed.history] + state.model = ctx.model ?? resumed.model + const resumedSavedVariant = state.model ? await resolveSavedVariant(state.model) : undefined + state.activeVariant = resolveVariant(ctx.variant, resumed.variant, resumedSavedVariant, []) + session.variant = state.activeVariant + footer.event({ type: "history", history: resumed.history }) + footer.event({ type: "first", first: resumed.first }) }) return state.session } + const modelTask = firstPaint.then(async () => { + if (footer.isClosed) return + await ensureSession() + if (footer.isClosed) return + return loadModel() + }) const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => { state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT) } @@ -640,6 +667,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep const runQueue = async () => { await firstPaint if (footer.isClosed) return + await ensureSession() + if (footer.isClosed) return + await modelTask + if (footer.isClosed) return let includeFiles = true if (state.demo) { await state.demo.start() @@ -728,7 +759,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep text: error instanceof Error ? error.message : String(error), phase: "start", source: "system", - messageID: MessageID.ascending(), + messageID: SessionMessage.ID.create(), } as const rememberLocal(commit) footer.append(commit) @@ -834,61 +865,62 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } } -// Local in-process mode. Creates an SDK client backed by a direct fetch to -// the in-process server, so no external HTTP server is needed. -export async function runInteractiveLocalMode(input: RunLocalInput): Promise { - const sdk = createOpencodeClient({ - baseUrl: "http://opencode.internal", - fetch: input.fetch, - directory: input.directory, - }) +// Deferred mode paints before session resolution. The caller may back the +// generated client with a transport that is still acquiring a daemon. +export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise { + const sdk = input.sdk let session: Promise | undefined - return runInteractiveRuntime({ - files: input.files, - initialInput: input.initialInput, - thinking: input.thinking, - backgroundSubagents: input.backgroundSubagents, - replay: input.replay, - replayLimit: input.replayLimit, - demo: input.demo, - resolveSession: () => { - if (session) { + return runInteractiveRuntime( + { + files: input.files, + initialInput: input.initialInput, + thinking: input.thinking, + backgroundSubagents: input.backgroundSubagents, + replay: input.replay, + replayLimit: input.replayLimit, + demo: input.demo, + tuiConfig: input.tuiConfig, + resolveSession: () => { + if (session) { + return session + } + + session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => { + if (!next?.id) { + throw new Error("Session not found") + } + + return { + sessionID: next.id, + sessionTitle: next.title, + agent, + resume: next.resume, + } + }) return session - } - - session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => { - if (!next?.id) { - throw new Error("Session not found") - } - + }, + createSession: createSessionResolver(input.createSession), + boot: async () => { return { - sessionID: next.id, - sessionTitle: next.title, - agent, + sdk, + directory: input.directory, + sessionID: "", + sessionTitle: undefined, + resume: false, + agent: input.agent, + model: input.model, + variant: input.variant, } - }) - return session + }, }, - createSession: createSessionResolver(input.createSession), - boot: async () => { - return { - sdk, - directory: input.directory, - sessionID: "", - sessionTitle: undefined, - resume: false, - agent: input.agent, - model: input.model, - variant: input.variant, - } - }, - }) + deps, + ) } // Attach mode. Uses the caller-provided SDK client directly. export async function runInteractiveMode( - input: RunInput & { createSession?: CreateSession }, + input: RunInput & { createSession?: CreateSession; tuiConfig?: RunTuiConfig | Promise }, deps?: RunRuntimeDeps, ): Promise { return runInteractiveRuntime( @@ -900,6 +932,7 @@ export async function runInteractiveMode( replay: input.replay, replayLimit: input.replayLimit, demo: input.demo, + tuiConfig: input.tuiConfig, boot: async () => ({ sdk: input.sdk, directory: input.directory, diff --git a/packages/opencode/src/cli/cmd/run/scrollback.shared.ts b/packages/cli/src/mini/scrollback.shared.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/scrollback.shared.ts rename to packages/cli/src/mini/scrollback.shared.ts diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/cli/src/mini/scrollback.surface.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/scrollback.surface.ts rename to packages/cli/src/mini/scrollback.surface.ts diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/cli/src/mini/scrollback.writer.tsx similarity index 100% rename from packages/opencode/src/cli/cmd/run/scrollback.writer.tsx rename to packages/cli/src/mini/scrollback.writer.tsx diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/cli/src/mini/session-data.ts similarity index 99% rename from packages/opencode/src/cli/cmd/run/session-data.ts rename to packages/cli/src/mini/session-data.ts index ffce45bc945..263b8e86d45 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/cli/src/mini/session-data.ts @@ -25,7 +25,7 @@ // event arrives, the queue entry is removed and the footer falls back // to the next pending request or to the prompt view. import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" -import * as Locale from "@/util/locale" +import { Locale } from "@opencode-ai/tui/util/locale" import { toolView } from "./tool" import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types" diff --git a/packages/opencode/src/cli/cmd/run/session.shared.ts b/packages/cli/src/mini/session.shared.ts similarity index 90% rename from packages/opencode/src/cli/cmd/run/session.shared.ts rename to packages/cli/src/mini/session.shared.ts index b39624662ce..e68df9e6c26 100644 --- a/packages/opencode/src/cli/cmd/run/session.shared.ts +++ b/packages/cli/src/mini/session.shared.ts @@ -4,11 +4,12 @@ // the prompt history ring. Also finds the most recently used variant for // the current model so the footer can pre-select it. import { promptCopy, promptSame } from "./prompt.shared" +import type { Message, Part } from "@opencode-ai/sdk/v2" import type { RunInput, RunPrompt } from "./types" const LIMIT = 200 -export type SessionMessages = NonNullable>["data"]> +export type SessionMessages = Array<{ info: Message; parts: Part[] }> type Turn = { prompt: RunPrompt @@ -160,10 +161,10 @@ export async function resolveCurrentSession( limit = LIMIT, ): Promise { const [response, session] = await Promise.all([ - sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }), - sdk.v2.session.get({ sessionID }, { throwOnError: true }), + sdk.message.list({ sessionID, limit, order: "desc" }), + sdk.session.get({ sessionID }), ]) - const messages = response.data.data.toReversed() + const messages = response.data.toReversed() return { first: messages.length === 0, turns: messages.flatMap((message) => { @@ -195,18 +196,18 @@ export async function resolveCurrentSession( })), ], }, - provider: session.data.data.model?.providerID, - model: session.data.data.model?.id, - variant: session.data.data.model?.variant, + provider: session.model?.providerID, + model: session.model?.id, + variant: session.model?.variant, }, ] }), - ...(session.data.data.model && { + ...(session.model && { model: { - providerID: session.data.data.model.providerID, - modelID: session.data.data.model.id, + providerID: session.model.providerID, + modelID: session.model.id, }, - variant: session.data.data.model.variant, + variant: session.model.variant, }), } } diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/cli/src/mini/splash.ts similarity index 98% rename from packages/opencode/src/cli/cmd/run/splash.ts rename to packages/cli/src/mini/splash.ts index 9e9321cafd4..e39adba3760 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/cli/src/mini/splash.ts @@ -17,8 +17,8 @@ import { type ScrollbackSnapshot, type ScrollbackWriter, } from "@opentui/core" -import * as Locale from "@/util/locale" -import { go } from "@/cli/logo" +import { Locale } from "@opencode-ai/tui/util/locale" +import { go } from "@opencode-ai/tui/logo" import type { RunSplashTheme } from "./theme" export const SPLASH_TITLE_LIMIT = 50 diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts similarity index 96% rename from packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts rename to packages/cli/src/mini/stream-v2.subagent.ts index 00de6490c44..ad34f6ecff3 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -15,14 +15,9 @@ // Per-child interruption uses `v2.session.interrupt(childID)`. Per-child // backgrounding is intentionally absent: subagent jobs block the parent // session, so only whole-session `v2.session.background(parentID)` exists. -import type { - OpencodeClient, - SessionMessage, - SessionMessageAssistantTool, - ToolPart, - V2Event, -} from "@opencode-ai/sdk/v2" -import { Locale } from "@/util/locale" +import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" +import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2" +import { Locale } from "@opencode-ai/tui/util/locale" import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" const CHILD_MESSAGE_LIMIT = 80 @@ -31,6 +26,8 @@ const CHILD_EVENT_BUFFER_LIMIT = 64 const FAMILY_LIST_LIMIT = 100 const FALLBACK_LABEL = "Subagent" +type V2Event = EventSubscribeOutput + export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) { return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n") } @@ -165,7 +162,7 @@ type ChildState = { } export type SubagentTrackerInput = { - sdk: OpencodeClient + sdk: OpenCodeClient sessionID: string thinking: boolean emit: () => void @@ -390,8 +387,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const pendingPrompts = new Map(child.prompts) const pendingTools = new Map(child.tools) let retry = false - const task = input.sdk.v2.session - .messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true }) + const task = input.sdk.message + .list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }) .then((response) => { const buffered = hydrationEvents.get(child.sessionID) ?? [] hydrationEvents.delete(child.sessionID) @@ -404,7 +401,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac for (const [id, prompt] of pendingPrompts) { if (!child.prompts.has(id)) child.prompts.set(id, prompt) } - rebuild(child, response.data.data.toReversed()) + rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[]) for (const [id, tool] of pendingTools) { if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool) } @@ -428,10 +425,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return checked.add(sessionID) if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, []) - void input.sdk.v2.session - .get({ sessionID }, { throwOnError: true }) - .then((response) => { - const session = response.data.data + void input.sdk.session + .get({ sessionID }) + .then((session) => { const buffered = pendingEvents.get(sessionID) ?? [] pendingEvents.delete(sessionID) if (session.parentID !== input.sessionID) return @@ -558,14 +554,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac }) childTool( child, - { + structuredClone({ type: "tool", id: event.data.callID, name: event.data.tool, provider: event.data.provider, state: { status: "running", input: event.data.input, structured: {}, content: [] }, time: { created: current?.started ?? event.created, ran: event.created }, - }, + }) as SessionMessageAssistantTool, event.data.assistantMessageID, ) touch(child, event.created) @@ -578,7 +574,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const failed = event.type === "session.tool.failed" childTool( child, - { + structuredClone({ type: "tool", id: event.data.callID, name: current?.name ?? "tool", @@ -605,7 +601,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac ran: current?.started, completed: event.created, }, - }, + }) as SessionMessageAssistantTool, event.data.assistantMessageID, ) touch(child, event.created) @@ -697,9 +693,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac } // Family index: adopt children directly from the current session list so // historical subagents beyond the projected message window still get tabs. - const family = await input.sdk.v2.session - .list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true }) - .then((response) => response.data.data) + const family = await input.sdk.session + .list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }) + .then((response) => response.data) .catch(() => []) for (const session of family) { const child = ensureChild(session.id) diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts similarity index 93% rename from packages/opencode/src/cli/cmd/run/stream-v2.transport.ts rename to packages/cli/src/mini/stream-v2.transport.ts index 8b80b0b64e1..7cec6f9e87f 100644 --- a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -1,13 +1,13 @@ import type { - OpencodeClient, + EventSubscribeOutput, + OpenCodeClient, +} from "@opencode-ai/client/promise" +import type { PermissionRequest, - PermissionV2Request, QuestionRequest, - QuestionV2Request, SessionMessage, SessionMessageAssistant, SessionMessageAssistantTool, - V2Event, } from "@opencode-ai/sdk/v2" import { Event } from "@opencode-ai/schema/event" import { blockerStatus, pickBlockerView } from "./session-data" @@ -31,7 +31,7 @@ type Trace = { } type StreamInput = { - sdk: OpencodeClient + sdk: OpenCodeClient directory?: string sessionID: string thinking: boolean @@ -90,7 +90,9 @@ type ShellWait = { abort: () => void } -type RunV2Event = V2Event +type RunV2Event = EventSubscribeOutput +type PermissionV2Request = Extract["data"] +type QuestionV2Request = Extract["data"] type PromptFilePart = Extract type ToolState = { @@ -144,9 +146,9 @@ function permission(request: PermissionV2Request): PermissionRequest { id: request.id, sessionID: request.sessionID, permission: request.action, - patterns: request.resources, + patterns: [...request.resources], metadata: request.metadata ?? {}, - always: request.save ?? [], + always: [...(request.save ?? [])], tool: request.source?.type === "tool" ? request.source : undefined, } } @@ -155,7 +157,7 @@ function question(request: QuestionV2Request): QuestionRequest { return { id: request.id, sessionID: request.sessionID, - questions: request.questions, + questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })), tool: request.tool, } } @@ -303,14 +305,14 @@ async function resolveSelectedModel(input: StreamInput, next: Pick response.data.data.model) + const session = await input.sdk.session + .get({ sessionID: input.sessionID }, { signal: next.signal }) + .then((response) => response.model) if (session) return { ...session, variant: next.variant } - const fallback = await input.sdk.v2.model - .default(undefined, { throwOnError: true, signal: next.signal }) - .then((response) => response.data.data) + const fallback = await input.sdk.model + .default(undefined, { signal: next.signal }) + .then((response) => response.data) if (!fallback) return return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } } @@ -532,21 +534,18 @@ export async function createSessionTransport(input: StreamInput): Promise { const [messages, permissions, questions, active] = await Promise.all([ - input.sdk.v2.session.messages( - { sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }, - { throwOnError: true }, - ), - input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }), - input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }), - input.sdk.v2.session.active({ throwOnError: true }), + input.sdk.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }), + input.sdk.permission.list({ sessionID: input.sessionID }), + input.sdk.question.list({ sessionID: input.sessionID }), + input.sdk.session.active(), ]) - const projected = messages.data.data.toReversed() + const projected = structuredClone(messages.data).toReversed() as SessionMessage[] for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait) - state.permissions = permissions.data.data.map(permission) - state.questions = questions.data.data.map(question) + state.permissions = permissions.map(permission) + state.questions = questions.map(question) syncBlockers() - await subagents.hydrate({ messages: projected, active: active.data.data }) - const running = input.sessionID in active.data.data + await subagents.hydrate({ messages: [...projected], active: active.data }) + const running = input.sessionID in active.data write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" }) if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) { const current = state.wait @@ -721,14 +720,14 @@ export async function createSessionTransport(input: StreamInput): Promise connection.abort() controller.signal.addEventListener("abort", abortConnection, { once: true }) - const response = await input.sdk.v2.event.subscribe({ - signal: connection.signal, - sseMaxRetryAttempts: 0, - throwOnError: true, - }) - const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator + const stream = input.sdk.event.subscribe({ signal: connection.signal })[Symbol.asyncIterator]() try { const first = await stream.next() if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected") @@ -910,9 +904,9 @@ export async function createSessionTransport(input: StreamInput): Promise { active.interrupted = true - void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + void input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } next.signal?.addEventListener("abort", interrupt, { once: true }) try { @@ -981,9 +975,9 @@ export async function createSessionTransport(input: StreamInput): Promise - input.sdk.v2.session.skill( + input.sdk.session.skill( { sessionID: input.sessionID, id: messageID, skill: command.name }, - { throwOnError: true, signal: next.signal }, + { signal: next.signal }, ), }) return @@ -1001,7 +995,7 @@ export async function createSessionTransport(input: StreamInput): Promise - input.sdk.v2.session.command( + input.sdk.session.command( { sessionID: input.sessionID, id: messageID, @@ -1013,24 +1007,24 @@ export async function createSessionTransport(input: StreamInput): Promise - input.sdk.v2.session.prompt( + input.sdk.session.prompt( { sessionID: input.sessionID, id: messageID, @@ -1053,7 +1047,7 @@ export async function createSessionTransport(input: StreamInput): Promise {}) + await input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) }, selectSubagent(sessionID) { subagents.select(sessionID) diff --git a/packages/opencode/src/cli/cmd/run/stream.ts b/packages/cli/src/mini/stream.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/stream.ts rename to packages/cli/src/mini/stream.ts diff --git a/packages/opencode/src/cli/cmd/run/theme.ts b/packages/cli/src/mini/theme.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/theme.ts rename to packages/cli/src/mini/theme.ts diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/cli/src/mini/tool.ts similarity index 82% rename from packages/opencode/src/cli/cmd/run/tool.ts rename to packages/cli/src/mini/tool.ts index 181ab7dc7ca..8d9b06aee1c 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/cli/src/mini/tool.ts @@ -16,25 +16,8 @@ import os from "os" import path from "path" import stripAnsi from "strip-ansi" import type { ToolPart } from "@opencode-ai/sdk/v2" -import type * as Tool from "@/tool/tool" -import type { ApplyPatchTool } from "@/tool/apply_patch" -import type { ShellTool as BashTool } from "@/tool/shell" -import type { EditTool } from "@/tool/edit" -import type { GlobTool } from "@/tool/glob" -import type { GrepTool } from "@/tool/grep" -import type { InvalidTool } from "@/tool/invalid" -import type { LspTool } from "@/tool/lsp" -import type { PlanExitTool } from "@/tool/plan" -import type { QuestionTool } from "@/tool/question" -import type { ReadTool } from "@/tool/read" -import type { SkillTool } from "@/tool/skill" -import type { TaskTool } from "@/tool/task" -import type { TodoWriteTool } from "@/tool/todo" -import type { WebFetchTool } from "@/tool/webfetch" -import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch" -import type { WriteTool } from "@/tool/write" -import { LANGUAGE_EXTENSIONS } from "@/lsp/language" -import * as Locale from "@/util/locale" +import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype" +import { Locale } from "@opencode-ai/tui/util/locale" import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types" export type ToolView = { @@ -47,6 +30,46 @@ export type ToolPhase = "start" | "progress" | "final" export type ToolDict = Record +type PatchFile = { + type?: string + relativePath?: string + filePath?: string + movePath?: string + patch?: string + deletions?: number +} + +type ToolInput = ToolDict & { + path?: string + pattern?: string + filePath?: string + filepath?: string + url?: string + query?: string + subagent_type?: string + description?: string + name?: string + operation?: string + line?: number + character?: number + content?: string + command?: string + workdir?: string + todos?: Array<{ status?: string; content?: string }> + questions?: Array<{ question?: string }> + diff?: string +} + +type ToolMetadata = ToolDict & { + count?: number + matches?: number + diff?: string + provider?: unknown + files?: PatchFile[] + answers?: string[][] + exit?: number +} + export type ToolFrame = { raw: string name: string @@ -73,15 +96,15 @@ export type ToolPermissionInfo = { file?: string } -export type ToolProps = { - input: Partial> - metadata: Partial> +export type ToolProps = { + input: ToolInput + metadata: ToolMetadata frame: ToolFrame } -type ToolPermissionProps = { - input: Partial> - metadata: Partial> +type ToolPermissionProps = { + input: ToolInput + metadata: ToolMetadata patterns: string[] } @@ -91,40 +114,35 @@ type ToolPermissionCtx = { patterns: string[] } -type ToolDefs = { - invalid: typeof InvalidTool - bash: typeof BashTool - write: typeof WriteTool - edit: typeof EditTool - apply_patch: typeof ApplyPatchTool - batch: Tool.Info - task: typeof TaskTool - todowrite: typeof TodoWriteTool - question: typeof QuestionTool - read: typeof ReadTool - glob: typeof GlobTool - grep: typeof GrepTool - list: Tool.Info - lsp: typeof LspTool - webfetch: typeof WebFetchTool - websearch: typeof WebSearchTool - skill: typeof SkillTool - plan_exit: typeof PlanExitTool -} +type ToolName = + | "invalid" + | "bash" + | "write" + | "edit" + | "apply_patch" + | "batch" + | "task" + | "todowrite" + | "question" + | "read" + | "glob" + | "grep" + | "list" + | "lsp" + | "webfetch" + | "websearch" + | "skill" + | "plan_exit" -type ToolName = keyof ToolDefs - -type ToolRule = { +type ToolRule = { view: ToolView - run: (props: ToolProps) => ToolInline - scroll?: Partial) => string>> - permission?: (props: ToolPermissionProps) => ToolPermissionInfo - snap?: (props: ToolProps) => ToolSnapshot | undefined + run: (props: ToolProps) => ToolInline + scroll?: Partial string>> + permission?: (props: ToolPermissionProps) => ToolPermissionInfo + snap?: (props: ToolProps) => ToolSnapshot | undefined } -type ToolRegistry = { - [K in ToolName]: ToolRule -} +type ToolRegistry = Record type AnyToolRule = ToolRule @@ -136,22 +154,28 @@ function dict(v: unknown): ToolDict { return { ...v } } -function props(frame: ToolFrame): ToolProps { +function props(frame: ToolFrame): ToolProps { return { - input: Object.assign(Object.create(null), frame.input), - metadata: Object.assign(Object.create(null), frame.meta), + input: frame.input, + metadata: frame.meta, frame, } } -function permission(ctx: ToolPermissionCtx): ToolPermissionProps { +function permission(ctx: ToolPermissionCtx): ToolPermissionProps { return { - input: Object.assign(Object.create(null), ctx.input), - metadata: Object.assign(Object.create(null), ctx.meta), + input: ctx.input, + metadata: ctx.meta, patterns: ctx.patterns, } } +function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + function text(v: unknown): string { return typeof v === "string" ? v : "" } @@ -285,7 +309,7 @@ function count(n: number, label: string): string { return `${n} ${label}${n === 1 ? "" : "es"}` } -function runGlob(p: ToolProps): ToolInline { +function runGlob(p: ToolProps): ToolInline { const root = p.input.path ?? "" const title = `Glob "${p.input.pattern ?? ""}"` const suffix = root ? `in ${toolPath(root)}` : "" @@ -298,7 +322,7 @@ function runGlob(p: ToolProps): ToolInline { } } -function runGrep(p: ToolProps): ToolInline { +function runGrep(p: ToolProps): ToolInline { const root = p.input.path ?? "" const title = `Grep "${p.input.pattern ?? ""}"` const suffix = root ? `in ${toolPath(root)}` : "" @@ -319,7 +343,7 @@ function runList(p: ToolProps): ToolInline { } } -function runRead(p: ToolProps): ToolInline { +function runRead(p: ToolProps): ToolInline { const file = toolPath(p.input.filePath) const description = info(p.frame.input, ["filePath"]) || undefined return { @@ -329,7 +353,7 @@ function runRead(p: ToolProps): ToolInline { } } -function runWrite(p: ToolProps): ToolInline { +function runWrite(p: ToolProps): ToolInline { return { icon: "←", title: `Write ${toolPath(p.input.filePath)}`, @@ -338,7 +362,7 @@ function runWrite(p: ToolProps): ToolInline { } } -function runWebfetch(p: ToolProps): ToolInline { +function runWebfetch(p: ToolProps): ToolInline { const url = p.input.url ?? "" return { icon: "%", @@ -346,7 +370,7 @@ function runWebfetch(p: ToolProps): ToolInline { } } -function runEdit(p: ToolProps): ToolInline { +function runEdit(p: ToolProps): ToolInline { return { icon: "←", title: `Edit ${toolPath(p.input.filePath)}`, @@ -355,7 +379,7 @@ function runEdit(p: ToolProps): ToolInline { } } -function runWebSearch(p: ToolProps): ToolInline { +function runWebSearch(p: ToolProps): ToolInline { const title = webSearchProviderLabel(p.metadata.provider) return { icon: "◈", @@ -363,7 +387,7 @@ function runWebSearch(p: ToolProps): ToolInline { } } -function runTask(p: ToolProps): ToolInline { +function runTask(p: ToolProps): ToolInline { const kind = Locale.titlecase(p.input.subagent_type || "unknown") const desc = p.input.description const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓" @@ -374,7 +398,7 @@ function runTask(p: ToolProps): ToolInline { } } -function runTodo(p: ToolProps): ToolInline { +function runTodo(p: ToolProps): ToolInline { return { icon: "#", title: "Todos", @@ -393,14 +417,14 @@ function runTodo(p: ToolProps): ToolInline { } } -function runSkill(p: ToolProps): ToolInline { +function runSkill(p: ToolProps): ToolInline { return { icon: "→", title: `Skill "${p.input.name ?? ""}"`, } } -function runPatch(p: ToolProps): ToolInline { +function runPatch(p: ToolProps): ToolInline { const files = p.metadata.files?.length ?? 0 if (files === 0) { return { @@ -415,7 +439,7 @@ function runPatch(p: ToolProps): ToolInline { } } -function runQuestion(p: ToolProps): ToolInline { +function runQuestion(p: ToolProps): ToolInline { const total = list(p.frame.input.questions).length return { icon: "→", @@ -423,7 +447,7 @@ function runQuestion(p: ToolProps): ToolInline { } } -function runInvalid(p: ToolProps): ToolInline { +function runInvalid(p: ToolProps): ToolInline { return { icon: "✗", title: text(p.frame.state.title) || "Invalid Tool", @@ -463,14 +487,14 @@ function lspTitle( return `LSP ${op} ${file}${pos}` } -function runLsp(p: ToolProps): ToolInline { +function runLsp(p: ToolProps): ToolInline { return { icon: "→", title: text(p.frame.state.title) || lspTitle(p.input), } } -function runPlanExit(p: ToolProps): ToolInline { +function runPlanExit(p: ToolProps): ToolInline { return { icon: "→", title: text(p.frame.state.title) || "Switching to build agent", @@ -479,8 +503,6 @@ function runPlanExit(p: ToolProps): ToolInline { } } -type PatchFile = Tool.InferMetadata["files"][number] - function patchTitle(file: PatchFile): string { const rel = file.relativePath const from = file.filePath @@ -497,7 +519,7 @@ function patchTitle(file: PatchFile): string { return `# Patched ${rel || toolPath(from)}` } -function snapWrite(p: ToolProps): ToolSnapshot | undefined { +function snapWrite(p: ToolProps): ToolSnapshot | undefined { const file = p.input.filePath || "" const content = p.input.content || "" if (!file && !content) { @@ -512,7 +534,7 @@ function snapWrite(p: ToolProps): ToolSnapshot | undefined { } } -function snapEdit(p: ToolProps): ToolSnapshot | undefined { +function snapEdit(p: ToolProps): ToolSnapshot | undefined { const file = p.input.filePath || "" const diff = p.metadata.diff || "" if (!file || !diff.trim()) { @@ -531,7 +553,7 @@ function snapEdit(p: ToolProps): ToolSnapshot | undefined { } } -function snapPatch(p: ToolProps): ToolSnapshot | undefined { +function snapPatch(p: ToolProps): ToolSnapshot | undefined { const files = list(p.frame.meta.files) if (files.length === 0) { return undefined @@ -568,7 +590,7 @@ function snapPatch(p: ToolProps): ToolSnapshot | undefine } } -function snapTask(p: ToolProps): ToolSnapshot { +function snapTask(p: ToolProps): ToolSnapshot { const kind = Locale.titlecase(p.input.subagent_type || "general") const desc = p.input.description const title = text(p.frame.state.title) @@ -582,7 +604,7 @@ function snapTask(p: ToolProps): ToolSnapshot { } } -function snapTodo(p: ToolProps): ToolSnapshot { +function snapTodo(p: ToolProps): ToolSnapshot { const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => { const content = typeof item?.content === "string" ? item.content : "" if (!content) { @@ -604,7 +626,7 @@ function snapTodo(p: ToolProps): ToolSnapshot { } } -function snapQuestion(p: ToolProps): ToolSnapshot { +function snapQuestion(p: ToolProps): ToolSnapshot { const answers = list(p.frame.meta.answers) const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => { const answer = list(answers[i]).filter((entry) => typeof entry === "string") @@ -621,7 +643,7 @@ function snapQuestion(p: ToolProps): ToolSnapshot { } } -function scrollBashStart(p: ToolProps): string { +function scrollBashStart(p: ToolProps): string { const cmd = p.input.command ?? "" const wd = p.input.workdir ?? "" const formatted = wd && wd !== "." ? toolPath(wd) : "" @@ -637,7 +659,7 @@ function scrollBashStart(p: ToolProps): string { return `# Running in ${dir}\n$ ${cmd}` } -function scrollBashProgress(p: ToolProps): string { +function scrollBashProgress(p: ToolProps): string { const out = stripAnsi(p.frame.raw) const cmd = (p.input.command ?? "").trim() const fmt = (text: string) => { @@ -670,7 +692,7 @@ function scrollBashProgress(p: ToolProps): string { return fmt(out) } -function scrollBashFinal(p: ToolProps): string { +function scrollBashFinal(p: ToolProps): string { if (p.frame.status === "error") { return fail(p.frame) } @@ -688,22 +710,22 @@ function scrollBashFinal(p: ToolProps): string { return `bash completed (exit ${code})${time ? ` · ${time}` : ""}` } -function scrollReadStart(p: ToolProps): string { +function scrollReadStart(p: ToolProps): string { const file = toolPath(p.input.filePath) const extra = info(p.frame.input, ["filePath"]) const tail = extra ? ` ${extra}` : "" return `→ Read ${file}${tail}`.trim() } -function scrollWriteStart(_: ToolProps): string { +function scrollWriteStart(_: ToolProps): string { return "" } -function scrollEditStart(_: ToolProps): string { +function scrollEditStart(_: ToolProps): string { return "" } -function scrollPatchStart(_: ToolProps): string { +function scrollPatchStart(_: ToolProps): string { return "" } @@ -727,7 +749,7 @@ function patchLine(file: PatchFile): string { return `~ Patched ${rel || toolPath(from)}` } -function scrollPatchFinal(p: ToolProps): string { +function scrollPatchFinal(p: ToolProps): string { if (p.frame.status === "error") { return fail(p.frame) } @@ -756,7 +778,7 @@ function scrollPatchFinal(p: ToolProps): string { return patchLine(files[0]!) } -function scrollTaskStart(_: ToolProps): string { +function scrollTaskStart(_: ToolProps): string { return "" } @@ -778,7 +800,7 @@ function taskResult(output: string): string | undefined { return next || undefined } -function scrollTaskFinal(p: ToolProps): string { +function scrollTaskFinal(p: ToolProps): string { if (p.frame.status === "error") { return fail(p.frame) } @@ -792,11 +814,11 @@ function scrollTaskFinal(p: ToolProps): string { return `# ${kind} Task\n${row}` } -function scrollTodoStart(_: ToolProps): string { +function scrollTodoStart(_: ToolProps): string { return "" } -function scrollTodoFinal(p: ToolProps): string { +function scrollTodoFinal(p: ToolProps): string { const items = list<{ status?: string }>(p.input.todos) const time = span(p.frame.state) if (items.length === 0) { @@ -828,11 +850,11 @@ function scrollTodoFinal(p: ToolProps): string { return tail.join(" · ") } -function scrollQuestionStart(_: ToolProps): string { +function scrollQuestionStart(_: ToolProps): string { return "" } -function scrollQuestionFinal(p: ToolProps): string { +function scrollQuestionFinal(p: ToolProps): string { const q = p.input.questions ?? [] const a = p.metadata.answers ?? [] const time = span(p.frame.state) @@ -859,15 +881,15 @@ function scrollQuestionFinal(p: ToolProps): string { return rows.join("\n") } -function scrollLspStart(p: ToolProps): string { +function scrollLspStart(p: ToolProps): string { return `→ ${lspTitle(p.input)}` } -function scrollSkillStart(p: ToolProps): string { +function scrollSkillStart(p: ToolProps): string { return `→ Skill "${p.input.name ?? ""}"` } -function scrollGlobStart(p: ToolProps): string { +function scrollGlobStart(p: ToolProps): string { const pattern = p.input.pattern ?? "" const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob" const dir = p.input.path ?? "" @@ -878,11 +900,11 @@ function scrollGlobStart(p: ToolProps): string { return `${head} in ${toolPath(dir)}` } -function scrollGlobFinal(p: ToolProps): string { +function scrollGlobFinal(p: ToolProps): string { return toolError(p.frame) || fail(p.frame) } -function scrollGrepStart(p: ToolProps): string { +function scrollGrepStart(p: ToolProps): string { const pattern = p.input.pattern ?? "" const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep" const dir = p.input.path ?? "" @@ -902,7 +924,7 @@ function scrollListStart(p: ToolProps): string { return `→ List ${toolPath(dir)}` } -function scrollWebfetchStart(p: ToolProps): string { +function scrollWebfetchStart(p: ToolProps): string { const url = p.input.url ?? "" if (!url) { return "% WebFetch" @@ -911,7 +933,7 @@ function scrollWebfetchStart(p: ToolProps): string { return `% WebFetch ${url}` } -function scrollWebSearchStart(p: ToolProps): string { +function scrollWebSearchStart(p: ToolProps): string { const title = webSearchProviderLabel(p.metadata.provider) const query = p.input.query ?? "" if (!query) { @@ -921,7 +943,7 @@ function scrollWebSearchStart(p: ToolProps): string { return `◈ ${title} "${query}"` } -function permEdit(p: ToolPermissionProps): ToolPermissionInfo { +function permEdit(p: ToolPermissionProps): ToolPermissionInfo { const input = p.input as { filePath?: string; filepath?: string; diff?: string } const file = input.filePath || input.filepath || p.patterns[0] || "" return { @@ -933,7 +955,7 @@ function permEdit(p: ToolPermissionProps): ToolPermissionInfo { } } -function permRead(p: ToolPermissionProps): ToolPermissionInfo { +function permRead(p: ToolPermissionProps): ToolPermissionInfo { const file = p.input.filePath || p.patterns[0] || "" return { icon: "→", @@ -942,7 +964,7 @@ function permRead(p: ToolPermissionProps): ToolPermissionInfo { } } -function permGlob(p: ToolPermissionProps): ToolPermissionInfo { +function permGlob(p: ToolPermissionProps): ToolPermissionInfo { const pattern = p.input.pattern || p.patterns[0] || "" return { icon: "✱", @@ -951,7 +973,7 @@ function permGlob(p: ToolPermissionProps): ToolPermissionInfo { } } -function permGrep(p: ToolPermissionProps): ToolPermissionInfo { +function permGrep(p: ToolPermissionProps): ToolPermissionInfo { const pattern = p.input.pattern || p.patterns[0] || "" return { icon: "✱", @@ -969,7 +991,7 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo { } } -function permBash(p: ToolPermissionProps): ToolPermissionInfo { +function permBash(p: ToolPermissionProps): ToolPermissionInfo { const cmd = p.input.command || "" return { icon: "#", @@ -978,7 +1000,7 @@ function permBash(p: ToolPermissionProps): ToolPermissionInfo { } } -function permTask(p: ToolPermissionProps): ToolPermissionInfo { +function permTask(p: ToolPermissionProps): ToolPermissionInfo { const type = p.input.subagent_type || "general" const desc = p.input.description return { @@ -988,7 +1010,7 @@ function permTask(p: ToolPermissionProps): ToolPermissionInfo { } } -function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo { +function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo { const url = p.input.url || "" return { icon: "%", @@ -997,7 +1019,7 @@ function permWebfetch(p: ToolPermissionProps): ToolPermissi } } -function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo { +function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo { const query = p.input.query || "" const title = webSearchProviderLabel(p.metadata.provider) return { @@ -1007,7 +1029,7 @@ function permWebSearch(p: ToolPermissionProps): ToolPermis } } -function permLsp(p: ToolPermissionProps): ToolPermissionInfo { +function permLsp(p: ToolPermissionProps): ToolPermissionInfo { const file = p.input.filePath || "" const line = typeof p.input.line === "number" ? p.input.line : undefined const char = typeof p.input.character === "number" ? p.input.character : undefined @@ -1273,7 +1295,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame { } } -function runBash(p: ToolProps): ToolInline { +function runBash(p: ToolProps): ToolInline { return { icon: "$", title: p.input.command || "", diff --git a/packages/opencode/src/cli/cmd/run/trace.ts b/packages/cli/src/mini/trace.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/trace.ts rename to packages/cli/src/mini/trace.ts diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/cli/src/mini/turn-summary.ts similarity index 100% rename from packages/opencode/src/cli/cmd/run/turn-summary.ts rename to packages/cli/src/mini/turn-summary.ts diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/cli/src/mini/types.ts similarity index 90% rename from packages/opencode/src/cli/cmd/run/types.ts rename to packages/cli/src/mini/types.ts index 2c489e26261..1077dc1c9f1 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/cli/src/mini/types.ts @@ -11,7 +11,8 @@ // → stream.ts bridges to footer API // → footer.ts queues commits and patches the footer view // → OpenTUI split-footer renderer writes to terminal -import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise" +import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import type { TuiConfig } from "@opencode-ai/tui/config" export type RunFilePart = { @@ -21,10 +22,17 @@ export type RunFilePart = { mime: string } -type PromptModel = Parameters[0]["model"] -type PromptInput = Parameters[0] +type PromptModel = { providerID: string; modelID: string } -export type RunPromptPart = NonNullable[number] +export type RunPromptPart = + | { + type: "file" + url: string + filename?: string + mime?: string + source?: FilePart["source"] + } + | { type: "agent"; name: string; source?: { start: number; end: number; value: string } } export type RunCommand = { name: string @@ -111,12 +119,10 @@ export type RunAgent = { hidden: boolean } -export type RunReference = NonNullable< - Awaited>["data"] ->["data"][number] +export type RunReference = ReferenceListOutput["data"][number] export type RunInput = { - sdk: OpencodeClient + sdk: OpenCodeClient directory: string sessionID: string sessionTitle?: string @@ -282,6 +288,10 @@ export type FooterOutput = { // transport both emit these to update footer state without reaching into // internal signals directly. export type FooterEvent = + | { + type: "history" + history: RunPrompt[] + } | { type: "catalog" agents: RunAgent[] @@ -342,11 +352,14 @@ export type FooterEvent = state: FooterSubagentState } -export type PermissionReply = Parameters[0] +export type PermissionReply = Omit[0], "sessionID"> -export type QuestionReply = Parameters[0] +export type QuestionReply = { + requestID: string + answers: string[][] +} -export type QuestionReject = Parameters[0] +export type QuestionReject = Omit[0], "sessionID"> export type RunTuiConfig = Pick diff --git a/packages/cli/src/mini/ui.ts b/packages/cli/src/mini/ui.ts new file mode 100644 index 00000000000..1caf2674fc5 --- /dev/null +++ b/packages/cli/src/mini/ui.ts @@ -0,0 +1,27 @@ +import { EOL } from "node:os" + +export const Style = { + TEXT_DIM: "\x1b[90m", + TEXT_NORMAL: "\x1b[0m", + TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m", + TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m", +} + +export function println(...message: string[]) { + process.stderr.write(message.join(" ") + EOL) +} + +let blank = false + +export function empty() { + if (blank) return + println(Style.TEXT_NORMAL) + blank = true +} + +export function error(message: string) { + if (message.startsWith("Error: ")) message = message.slice("Error: ".length) + println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message) +} + +export * as UI from "./ui" diff --git a/packages/opencode/src/cli/cmd/run/variant.shared.ts b/packages/cli/src/mini/variant.shared.ts similarity index 96% rename from packages/opencode/src/cli/cmd/run/variant.shared.ts rename to packages/cli/src/mini/variant.shared.ts index fa10af00559..196b4e040cc 100644 --- a/packages/opencode/src/cli/cmd/run/variant.shared.ts +++ b/packages/cli/src/mini/variant.shared.ts @@ -12,9 +12,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" -import { makeRuntime } from "@/effect/run-service" +import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Global } from "@opencode-ai/core/global" -import { isRecord } from "@/util/record" import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared" import type { RunInput, RunProvider } from "./types" @@ -32,6 +31,10 @@ type VariantRuntime = { saveVariant(model: RunInput["model"], variant: string | undefined): Promise } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + class Service extends Context.Service()("@opencode/RunVariant") {} function modelKey(provider: string, model: string): string { diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts new file mode 100644 index 00000000000..6f3af1f0600 --- /dev/null +++ b/packages/cli/src/server-process.ts @@ -0,0 +1,134 @@ +export * as ServerProcess from "./server-process" + +import { NodeServices } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { AppProcess } from "@opencode-ai/core/process" +import { Flock } from "@opencode-ai/core/util/flock" +import { start } from "@opencode-ai/server/process" +import { randomBytes, randomUUID } from "node:crypto" +import path from "node:path" +import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect" +import { HttpServer } from "effect/unstable/http" +import { Env } from "./env" +import { ServiceConfig } from "./services/service-config" +import { Updater } from "./services/updater" + +export type Mode = "default" | "service" | "stdio" + +export type Options = { + readonly mode: Mode + readonly hostname?: string + readonly port?: number +} + +export const run = Effect.fn("cli.server-process.run")((options: Options) => + processEffect(options).pipe( + Effect.provide(Updater.layer), + Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(NodeServices.layer), + ), +) + +const processEffect = Effect.fnUntraced(function* (options: Options) { + if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) + return yield* Effect.scoped( + Effect.gen(function* () { + if (options.mode === "service") { + const service = yield* ServiceConfig.options() + yield* Flock.effect("service-process", { + dir: path.dirname(service.file), + staleMs: 3_000, + timeoutMs: 15_000, + }) + } + const environmentPassword = yield* Env.password + // Keep the lease credential out of the environment inherited by tools. + if (options.mode === "stdio") { + delete process.env.OPENCODE_PASSWORD + delete process.env.OPENCODE_SERVER_PASSWORD + } + const config = options.mode === "service" ? yield* ServiceConfig.read() : {} + const password = + options.mode === "service" + ? yield* ServiceConfig.password() + : environmentPassword + ? Redacted.value(environmentPassword) + : randomBytes(32).toString("base64url") + if (!password) return yield* Effect.fail(new Error("Missing server password")) + const address = yield* start({ + hostname: options.hostname ?? config.hostname ?? "127.0.0.1", + port: Option.fromNullishOr(options.port ?? config.port), + password, + }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false }))) + if (options.mode === "service") yield* register(address, password) + const url = HttpServer.formatAddress(address) + console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) + if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`) + const updater = yield* Updater.Service + yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) + return yield* options.mode === "stdio" ? waitForStdinClose() : Effect.never + }).pipe(Effect.annotateLogs({ role: "server" })), + ) +}) + +// The latest atomic registration wins. A displaced process notices the new id, +// exits, and cannot remove its successor's registration from its finalizer. +const infoJson = Schema.fromJsonString(Service.Info) +const encodeInfo = Schema.encodeEffect(infoJson) +const decodeInfo = Schema.decodeUnknownEffect(infoJson) + +const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) { + const fs = yield* FileSystem.FileSystem + const options = yield* ServiceConfig.options() + const id = randomUUID() + const temp = options.file + "." + id + ".tmp" + yield* fs.makeDirectory(path.dirname(options.file), { recursive: true }) + const encoded = yield* encodeInfo({ + id, + version: InstallationVersion, + url: HttpServer.formatAddress(address), + pid: process.pid, + password, + }) + yield* fs.writeFileString(temp, encoded, { mode: 0o600 }) + yield* fs.rename(temp, options.file) + const currentID = fs.readFileString(options.file).pipe( + Effect.flatMap(decodeInfo), + Effect.map((info) => info.id), + Effect.orElseSucceed(() => undefined), + ) + yield* currentID.pipe( + Effect.flatMap((current) => + current === id + ? Effect.void + : Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore), + ), + Effect.repeat(Schedule.spaced("10 seconds")), + Effect.forkScoped, + ) + yield* Effect.addFinalizer(() => + currentID.pipe( + Effect.flatMap((current) => (current === id ? fs.remove(options.file) : Effect.void)), + Effect.ignore, + ), + ) +}) + +function waitForStdinClose() { + return Effect.callback((resume) => { + const close = () => resume(Effect.void) + process.stdin.once("end", close) + process.stdin.once("close", close) + process.stdin.resume() + if (process.stdin.readableEnded || process.stdin.destroyed) close() + return Effect.sync(() => { + process.stdin.off("end", close) + process.stdin.off("close", close) + process.stdin.pause() + }) + }) +} diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index 285a433b551..328eb0e302d 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -9,11 +9,17 @@ import path from "node:path" const Ready = Schema.Struct({ url: Schema.String }) const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready)) -function command(password: string) { +type Options = { + readonly command?: ReadonlyArray +} + +function command(password: string, options: Options) { const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : [] if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint") - return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], { + const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"] + if (!executable) throw new Error("Failed to resolve standalone server command") + return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], { cwd: process.cwd(), // Explicit entry wins over anything inherited, so a user-exported // OPENCODE_PASSWORD cannot shadow the child's lease credential. @@ -28,11 +34,11 @@ function command(password: string) { }) } -export const transport = Effect.fn("cli.standalone.transport")( - function* () { +const makeTransport = Effect.fn("cli.standalone.transport")( + function* (options: Options) { const password = randomBytes(32).toString("base64url") const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const proc = yield* spawner.spawn(command(password)) + const proc = yield* spawner.spawn(command(password, options)) const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) const ready = yield* Effect.tryPromise(() => decodeReady(output)) @@ -41,4 +47,8 @@ export const transport = Effect.fn("cli.standalone.transport")( Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), ) +export function transport(options: Options = {}) { + return makeTransport(options) +} + export * as Standalone from "./standalone" diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts new file mode 100644 index 00000000000..95b74d24049 --- /dev/null +++ b/packages/cli/test/mini.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini" + +async function cli(args: string[]) { + const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { + cwd: new URL("..", import.meta.url).pathname, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { stdout, stderr, exitCode } +} + +describe("mini command", () => { + test("uses piped stdin as the initial prompt", () => { + expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin") + expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag") + }) + + test("keeps run as mini's non-interactive input mode", () => { + expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin") + expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin") + }) + + test("applies a variant to a resumed session's model", () => { + expect( + pickRunModel( + undefined, + "high", + { providerID: "session-provider", modelID: "session-model" }, + { providerID: "default-provider", modelID: "default-model" }, + ), + ).toEqual({ providerID: "session-provider", modelID: "session-model" }) + }) + + test("is registered in the preview CLI", async () => { + const result = await cli(["--help"]) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("mini Start the minimal interactive interface") + expect(result.stdout).toContain("run Run OpenCode with a message") + }) + + test("exposes run without legacy attach or command modes", async () => { + const result = await cli(["run", "--help"]) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("--server string") + expect(result.stdout).not.toContain("--attach") + expect(result.stdout).not.toContain("--command") + }) + + test("keeps option-like prompt text after the argument separator", async () => { + const result = await cli(["run", "--server", "http://127.0.0.1:1", "--", "--foo"]) + + expect(result.exitCode).toBe(1) + expect(result.stderr).not.toContain("You must provide a message") + }) + + test("uses the shared V2 server option instead of an attach command", async () => { + const result = await cli(["mini", "--help"]) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("--server string") + expect(result.stdout).not.toContain("SUBCOMMANDS") + }) + + test("routes local and explicit-server invocations into mini", async () => { + for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) { + const result = await cli(args) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("opencode mini requires a TTY stdout") + } + }) +}) diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index ca428f5aa62..03e9c072379 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -31,11 +31,14 @@ export type Options = { // Read-only lookup: registration file plus health check and version gate. // Never spawns; escalation to start() is the caller's policy. export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { + return (yield* discoverLocal(options))?.transport +}) + +const discoverLocal = Effect.fnUntraced(function* (options: Options) { const info = yield* read(options.file) if (info === undefined) return undefined if (options.version !== undefined && info.version !== options.version) return undefined - const found = yield* probe(info) - return found?.transport + return yield* probe(info, options.version) }) // Idempotent ensure-running: reuses a healthy compatible server, replaces a @@ -48,18 +51,29 @@ export const start = Effect.fn("service.start")(function* (options: Options = {} const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) - yield* Effect.try({ + const child = yield* Effect.try({ try: () => { - spawn(command, args, { detached: true, stdio: "ignore" }).unref() + const child = spawn(command, args, { detached: true, stdio: "ignore" }) + child.unref() + return child }, catch: (cause) => new Error("Failed to start server", { cause }), }) - return yield* discover(options).pipe( + return yield* discoverLocal(options).pipe( Effect.flatMap((found) => found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found), ), Effect.retry(poll), + Effect.tap((found) => + found.info.pid === child.pid + ? Effect.void + : Effect.sync(() => { + child.kill("SIGTERM") + }), + ), + Effect.map((found) => found.transport), + Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)), Effect.mapError(() => new Error("Failed to start server")), ) }) @@ -90,6 +104,9 @@ export const Info = Schema.Struct({ export type Info = typeof Info.Type const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) +const decodeHealth = Schema.decodeUnknownEffect( + Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }), +) // A missing or corrupt file means no valid info; callers treat both // the same (the registering server self-evicts, clients rediscover). @@ -105,18 +122,23 @@ type LocalService = { readonly transport: Transport } -const probe = Effect.fnUntraced(function* (info: Info) { +const probe = Effect.fnUntraced(function* (info: Info, version?: string) { const headers = info.password === undefined ? undefined : auth(info.password) - const healthy = yield* Effect.tryPromise(() => + const response = yield* Effect.tryPromise(() => fetch(new URL("/api/health", info.url), { headers, signal: AbortSignal.timeout(2_000), }), - ).pipe( - Effect.map((response) => response.ok), - Effect.orElseSucceed(() => false), + ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) + if (response === undefined || !response.ok) return undefined + const health = yield* Effect.tryPromise(() => response.json()).pipe( + Effect.flatMap(decodeHealth), + Effect.option, + Effect.map(Option.getOrUndefined), ) - if (!healthy) return undefined + if (health?.pid !== info.pid) return undefined + if (info.version !== undefined && health.version !== info.version) return undefined + if (version !== undefined && health.version !== version) return undefined return { info, transport: { url: info.url, headers } } satisfies LocalService }) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ac30f3c35c6..ac0efadb5c0 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -157,7 +157,7 @@ export type ProjectCopyError = { export const isProjectCopyError = (value: unknown): value is ProjectCopyError => typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError" -export type HealthGetOutput = { readonly healthy: true } +export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number } export type LocationGetInput = { readonly location?: { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 651e4263363..bea2dc59731 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,6 +85,7 @@ "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/client": "workspace:*", + "@opencode-ai/cli": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", diff --git a/packages/opencode/src/cli/cmd/mini.ts b/packages/opencode/src/cli/cmd/mini.ts deleted file mode 100644 index 96faa2e88e5..00000000000 --- a/packages/opencode/src/cli/cmd/mini.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { Argv } from "yargs" -import { cmd } from "./cmd" -import { UI } from "@/cli/ui" -import { resolveThreadDirectory } from "./tui" - -type ReplayArgs = { - replay?: boolean - noReplay?: boolean -} - -type MiniArgs = ReplayArgs & { - continue?: boolean - session?: string - fork?: boolean - replayLimit?: number -} - -type MiniLocalArgs = MiniArgs & { - project?: string - model?: string - agent?: string - prompt?: string - demo?: boolean -} - -type MiniAttachArgs = MiniArgs & { - url: string - dir?: string - password?: string - username?: string -} - -function replay(args: ReplayArgs) { - if (args.replay === true) { - UI.error("--replay is not supported; replay is enabled by default") - process.exitCode = 1 - return "invalid" as const - } - return args.replay === false || args.noReplay === true ? false : undefined -} - -function miniOptions(yargs: Argv) { - return yargs - .option("continue", { - alias: ["c"], - describe: "continue the last session", - type: "boolean", - }) - .option("session", { - alias: ["s"], - describe: "session id to continue", - type: "string", - }) - .option("fork", { - type: "boolean", - describe: "fork the session when continuing (use with --continue or --session)", - }) - .option("replay", { - type: "boolean", - hidden: true, - }) - .option("no-replay", { - type: "boolean", - describe: "disable session history replay on resume and after resize", - }) - .option("replay-limit", { - type: "number", - describe: "cap visible replay to the newest N messages", - }) -} - -/** @internal Exported for CLI parser tests. */ -export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({ - command: "$0 [project]", - describe: "start the minimal interactive interface", - builder: (yargs) => - miniOptions( - yargs - .positional("project", { - type: "string", - describe: "path to start opencode in", - }) - .option("model", { - type: "string", - alias: ["m"], - describe: "model to use in the format of provider/model", - }) - .option("agent", { - type: "string", - describe: "agent to use", - }) - .option("prompt", { - type: "string", - describe: "prompt to use", - }) - .option("demo", { - type: "boolean", - hidden: true, - }), - ), - handler: async (args) => { - const shouldReplay = replay(args) - if (shouldReplay === "invalid") return - - const { runMini } = await import("./run") - await runMini({ - directory: resolveThreadDirectory(args.project), - continue: args.continue, - session: args.session, - fork: args.fork, - model: args.model, - agent: args.agent, - prompt: args.prompt, - replay: shouldReplay, - replayLimit: args.replayLimit, - demo: args.demo, - }) - }, -}) - -/** @internal Exported for CLI parser tests. */ -export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({ - command: "attach ", - describe: "attach to a running opencode server with the minimal interface", - builder: (yargs) => - miniOptions( - yargs - .positional("url", { - type: "string", - describe: "http://localhost:4096", - demandOption: true, - }) - .option("dir", { - type: "string", - describe: "directory on the remote server", - }) - .option("password", { - alias: ["p"], - type: "string", - describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", - }) - .option("username", { - alias: ["u"], - type: "string", - describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", - }), - ), - handler: async (args) => { - const shouldReplay = replay(args) - if (shouldReplay === "invalid") return - - const { runMini } = await import("./run") - await runMini({ - attach: args.url, - directory: args.dir, - password: args.password, - username: args.username, - continue: args.continue, - session: args.session, - fork: args.fork, - replay: shouldReplay, - replayLimit: args.replayLimit, - }) - }, -}) - -export const MiniCommand = cmd({ - command: "mini", - describe: "start the minimal interactive interface", - builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(), - handler: async () => {}, -}) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f56775e26c4..2021aadadd5 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,149 +1,12 @@ -import type { PermissionV1 } from "@opencode-ai/core/v1/permission" -import { FSUtil } from "@opencode-ai/core/fs-util" -// CLI entry point for `opencode run` and `opencode mini`. -// -// Handles three modes: -// 1. Non-interactive (default): sends a single prompt, streams events to -// stdout, and exits when the session goes idle. -// 2. Interactive local (`opencode mini`): boots the split-footer direct mode -// with an in-process server (no external HTTP). -// 3. Interactive attach (`opencode mini attach`): connects to a running -// opencode server and runs interactive mode against it. -// -// Also supports `--command` for slash-command execution, `--format json` for -// raw event streaming, `--continue` / `--session` for session resumption, -// and `--fork` for forking before continuing. -import type { Argv } from "yargs" -import path from "path" -import { pathToFileURL } from "url" -import { open } from "node:fs/promises" import { Effect } from "effect" -import { UI } from "../ui" import { effectCmd } from "../effect-cmd" -import { EOL } from "os" -import { Filesystem } from "@/util/filesystem" -import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" -import { FormatError, FormatUnknownError } from "../error" -import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -import { isImageAttachment, isPdfAttachment } from "@/util/media" -import { loadRunAgents, waitForCatalogReady } from "./run/catalog.shared" - -type ModelInput = Parameters[0]["model"] - -function pick(value: string | undefined): ModelInput | undefined { - if (!value) return undefined - const [providerID, ...rest] = value.split("/") - return { - providerID, - modelID: rest.join("/"), - } as ModelInput -} - -function resolveRunInput(value?: string, piped?: string): string | undefined { - if (!value) { - return piped - } - - if (!piped) { - return value - } - - return value + "\n" + piped -} - -function isBinaryContent(bytes: Uint8Array) { - if (bytes.length === 0) return false - if (bytes.includes(0)) return true - return ( - bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3 - ) -} - -type FilePart = { - type: "file" - url: string - filename: string - mime: string -} - -const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 - -type Inline = { - icon: string - title: string - description?: string -} - -type SessionInfo = { - id: string - title?: string - directory?: string - current?: boolean -} - -function inline(info: Inline) { - const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" - UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) -} - -function block(info: Inline, output?: string) { - UI.empty() - inline(info) - if (!output?.trim()) return - UI.println(output) - UI.empty() -} - -function formatRunError(error: unknown) { - return FormatError(error) ?? FormatUnknownError(error) -} - -async function tool(part: ToolPart) { - try { - const { toolInlineInfo } = await import("./run/tool") - const next = toolInlineInfo(part) - if (next.mode === "block") { - block(next, next.body) - return - } - - inline(next) - } catch { - inline({ - icon: "\u2699", - title: part.tool, - }) - } -} - -async function toolError(part: ToolPart) { - try { - const { toolInlineInfo } = await import("./run/tool") - const next = toolInlineInfo(part) - inline({ - icon: "✗", - title: `${next.title} failed`, - ...(next.description && { description: next.description }), - }) - return - } catch { - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - } -} +import { v2ServerCommand } from "./v2-server-command" export const RunCommand = effectCmd({ command: "run [message..]", describe: "run opencode with a message", - // --attach connects to a remote server (no local instance needed); the - // default path runs an in-process server and needs the project instance. - instance: (args) => !args.attach, - // For --dir without --attach, load instance for the resolved target dir. - // The handler also chdirs (preserving the legacy order: chdir → file resolution). - directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()), - builder: (yargs: Argv) => + instance: false, + builder: (yargs) => yargs .positional("message", { describe: "message to send", @@ -151,10 +14,6 @@ export const RunCommand = effectCmd({ array: true, default: [], }) - .option("command", { - describe: "the command to run, use message for args", - type: "string", - }) .option("continue", { alias: ["c"], describe: "continue the last session", @@ -180,8 +39,8 @@ export const RunCommand = effectCmd({ }) .option("format", { type: "string", - choices: ["default", "json"], - default: "default", + choices: ["default", "json"] as const, + default: "default" as const, describe: "format: default (formatted) or json (raw JSON events)", }) .option("file", { @@ -194,53 +53,36 @@ export const RunCommand = effectCmd({ type: "string", describe: "title for the session (uses truncated prompt if no value provided)", }) + .option("server", { + type: "string", + describe: "connect to a running opencode server", + }) .option("attach", { type: "string", - describe: "attach to a running opencode server (e.g., http://localhost:4096)", + hidden: true, }) .option("password", { alias: ["p"], type: "string", - describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", + hidden: true, }) .option("username", { alias: ["u"], type: "string", - describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + hidden: true, }) .option("dir", { type: "string", - describe: "directory to run in, path on remote server if attaching", - }) - .option("port", { - type: "number", - describe: "port for the local server (defaults to random port if no value provided)", + describe: "directory to run in, or a path on the remote server", }) .option("variant", { type: "string", - describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)", + describe: "model variant", }) .option("thinking", { type: "boolean", describe: "show thinking blocks", }) - .option("replay", { - type: "boolean", - default: true, - hidden: true, - describe: "replay interactive session history on resume and after resize (use --no-replay to disable)", - }) - .option("replay-limit", { - type: "number", - hidden: true, - describe: "cap visible interactive replay to the newest N messages", - }) - .option("interactive", { - alias: ["i"], - type: "boolean", - describe: "run in direct interactive split-footer mode", - default: false, - }) .option("auto", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", @@ -255,899 +97,29 @@ export const RunCommand = effectCmd({ type: "boolean", hidden: true, default: false, - }) - .option("demo", { - type: "boolean", - default: false, - hidden: true, - describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { - const { Agent } = yield* Effect.promise(() => import("@/agent/agent")) - const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags")) - const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref")) - const { ServerAuth } = yield* Effect.promise(() => import("@/server/auth")) - const agentSvc = yield* Agent.Service - const flags = yield* RuntimeFlags.Service - const localInstance = yield* InstanceRef - yield* Effect.promise(async () => { - const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") - const interactive = (args as typeof args & { mini?: boolean }).mini === true - const auto = args.auto || args.yolo || args["dangerously-skip-permissions"] - const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false) - const die = (message: string): never => { - UI.error(message) - process.exit(1) - } - const dieInteractive = (error: unknown): never => { - if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) { - die(error.message) - } - - throw error - } - - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") - - if (interactive && args.command) { - die("opencode mini cannot be used with --command") - } - - if (interactive && args._?.[0] !== "mini") { - die("opencode mini must be run with the mini command") - } - - if (args.demo && !interactive) { - die("--demo requires opencode mini") - } - - if (interactive && args.format === "json") { - die("opencode mini cannot be used with --format json") - } - - if (args["replay-limit"] !== undefined && !interactive) { - die("--replay-limit requires opencode mini") - } - - if ( - args["replay-limit"] !== undefined && - (!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0) - ) { - die("--replay-limit must be a positive integer") - } - - if (interactive && !process.stdout.isTTY) { - die("opencode mini requires a TTY stdout") - } - - if (interactive) { - try { - resolveInteractiveStdin().cleanup?.() - } catch (error) { - dieInteractive(error) - } - } - - const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined - - const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) - const directory = (() => { - if (!args.dir) return args.attach ? undefined : root - if (args.attach) return args.dir - - try { - process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) - return process.cwd() - } catch { - UI.error("Failed to change directory to " + args.dir) - process.exit(1) - } - })() - const attachHeaders = args.attach - ? ServerAuth.headers({ password: args.password, username: args.username }) - : undefined - const attachSDK = (dir?: string) => { - return createOpencodeClient({ - baseUrl: args.attach!, - directory: dir, - headers: attachHeaders, - }) - } - - const files: FilePart[] = [] - const fileInputs: Array<{ - filePath: string - resolvedPath: string - stat: ReturnType - isDirectory: boolean - }> = [] - if (args.file) { - const list = Array.isArray(args.file) ? args.file : [args.file] - - for (const filePath of list) { - const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) - if (!(await Filesystem.exists(resolvedPath))) { - UI.error(`File not found: ${filePath}`) - process.exit(1) - } - - const stat = Filesystem.stat(resolvedPath) - const isDirectory = stat?.isDirectory() ?? false - if (args.attach && isDirectory) { - UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`) - process.exit(1) - } - fileInputs.push({ filePath, resolvedPath, stat, isDirectory }) - } - } - - const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() - message = resolveRunInput(message, piped) ?? "" - const initialInput = resolveRunInput(rawMessage, piped) - - if (message.trim().length === 0 && !args.command && !interactive) { - UI.error("You must provide a message or a command") - process.exit(1) - } - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) - } - - const rules: PermissionV1.Ruleset = interactive - ? [] - : [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] - const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory) - - const inlineFiles = interactive || currentPrompt - for (const file of fileInputs) { - const content = await (async () => { - if (file.isDirectory || !inlineFiles) return - if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) { - UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`) - process.exit(1) - } - const handle = await open(file.resolvedPath, "r") - try { - const opened = await handle.stat() - if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { - UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`) - process.exit(1) - } - if (opened.size === 0) return Buffer.alloc(0) - const buffer = Buffer.alloc(Number(opened.size)) - let offset = 0 - while (offset < buffer.length) { - const read = await handle.read(buffer, offset, buffer.length - offset, offset) - if (read.bytesRead === 0) break - offset += read.bytesRead - } - return buffer.subarray(0, offset) - } finally { - await handle.close() - } - })() - const detected = FSUtil.mimeType(file.resolvedPath) - const text = content?.toString("utf8") - const mime = file.isDirectory - ? "application/x-directory" - : isImageAttachment(detected) || isPdfAttachment(detected) - ? detected - : content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content) - ? "text/plain" - : detected - - files.push({ - type: "file", - url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href, - filename: path.basename(file.resolvedPath), - mime, - }) - } - - function title() { - if (args.title === undefined) return - if (args.title !== "") return args.title - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } - - async function currentSession(sdk: OpencodeClient, sessionID: string): Promise { - const listed = await sdk.v2.session - .list({ - directory: await current(sdk), - limit: 50, - order: "desc", - }) - .then((result) => result.data?.data.find((item) => item.id === sessionID)) - .catch(() => undefined) - const selected = - listed ?? - (await sdk.v2.session - .get({ sessionID }) - .then((result) => result.data?.data) - .catch(() => undefined)) - const legacy = - selected ?? - (await sdk.session - .get({ sessionID }) - .then((result) => result.data) - .catch(() => undefined)) - const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID) - if (!legacy && transcript === "empty") { - return - } - if (interactive && transcript === "legacy") { - throw new Error("Mini cannot resume a legacy Session transcript") - } - - return { - id: legacy?.id ?? sessionID, - title: legacy?.title, - directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk), - current: transcript !== "legacy", - } - } - - async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise { - if (session.current !== false) { - const forked = await sdk.v2.session.fork( - { sessionID: session.id, messageID: undefined }, - { throwOnError: true }, - ) - await waitForFork(sdk, session.id, forked.data.data.id) - return { - id: forked.data.data.id, - title: forked.data.data.title, - directory: forked.data.data.location.directory, - current: true, - } - } - - const forked = await sdk.session.fork({ - sessionID: session.id, - }) - const id = forked.data?.id - if (!id) { - return - } - - return { - id, - title: forked.data?.title ?? session.title, - directory: forked.data?.directory ?? session.directory, - current: false, - } - } - - async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) { - const parentHasMessages = await sdk.v2.session - .messages({ sessionID: parentID, limit: 1 }) - .then((result) => (result.data?.data.length ?? 0) > 0) - .catch(() => false) - if (!parentHasMessages) { - return - } - - const deadline = Date.now() + 3000 - while (Date.now() < deadline) { - const forkedHasMessages = await sdk.v2.session - .messages({ sessionID, limit: 1 }) - .then((result) => (result.data?.data.length ?? 0) > 0) - .catch(() => false) - if (forkedHasMessages) { - return - } - - await Bun.sleep(25) - } - } - - async function session(sdk: OpencodeClient): Promise { - if (args.session) { - const current = await currentSession(sdk, args.session) - if (!current) { - UI.error("Session not found") - process.exit(1) - } - if (!interactive && !currentPrompt && current.current !== false) { - throw new Error("This operation is not available for a current Session transcript") - } - - if (args.fork) { - return forkSession(sdk, current) - } - - return current - } - - const base = args.continue ? await currentRootSession(sdk) : undefined - if (base && !interactive && !currentPrompt && base.current !== false) { - throw new Error("This operation is not available for a current Session transcript") - } - - if (base && args.fork) { - return forkSession(sdk, base) - } - - if (base) { - return { - id: base.id, - title: base.title, - directory: base.directory, - current: "current" in base ? base.current : false, - } - } - - if (interactive || currentPrompt) { - const name = title() - const result = await sdk.v2.session.create({ - location: { directory: await current(sdk) }, - }) - const created = result.data?.data - if (!created) return - if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name }) - return { - id: created.id, - title: name ?? created.title, - directory: created.location.directory, - current: true, - } - } - - const name = title() - const result = await sdk.session.create({ - title: name, - permission: [...rules], - }) - const id = result.data?.id - if (!id) { - return - } - - return { - id, - title: result.data?.title ?? name, - directory: result.data?.directory, - current: false, - } - } - - async function currentRootSession(sdk: OpencodeClient): Promise { - const response = await sdk.v2.session.list({ - directory: await current(sdk), - limit: 50, - order: "desc", - }) - const root = (response.data?.data ?? []) - .filter((session) => !session.parentID) - .toSorted((a, b) => b.time.updated - a.time.updated)[0] - if (!root) return - return currentSession(sdk, root.id) - } - - async function transcriptKind(sdk: OpencodeClient, sessionID: string) { - const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0) - // Ordinary prompt flows assume a transcript with current messages is - // current-owned; only legacy-only modes (--command, directory - // attachments) still probe legacy history for mixed transcripts. - if (current && (interactive || currentPrompt)) return "current" as const - - const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0) - if (current) { - if (legacy) throw new Error("Session contains mixed legacy and current transcripts") - return "current" as const - } - if (legacy) return "legacy" as const - return "empty" as const - } - - async function createFreshSession( - sdk: OpencodeClient, - input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined }, - ): Promise { - const name = args.title !== undefined && args.title !== "" ? args.title : undefined - const result = await sdk.v2.session.create({ - agent: input.agent, - model: input.model - ? { - providerID: input.model.providerID, - id: input.model.modelID, - variant: input.variant, - } - : undefined, - location: { directory: await current(sdk) }, - }) - const created = result.data?.data - const id = created?.id - if (!id) { - throw new Error("Failed to create session") - } - if (name) await sdk.v2.session.rename({ sessionID: id, title: name }) - - return { - id, - title: name ?? created.title, - } - } - - async function current(sdk: OpencodeClient): Promise { - if (!args.attach) { - return directory ?? root - } - - const next = await sdk.v2.location - .get(undefined, { throwOnError: true }) - .then((x) => x.data?.directory) - .catch(() => undefined) - if (next) { - return next - } - - UI.error("Failed to resolve remote directory") - process.exit(1) - } - - async function localAgent() { - if (!args.agent) return undefined - const name = args.agent - - const entry = await Effect.runPromise( - agentSvc.get(name).pipe(Effect.provideService(InstanceRef, localInstance)), - ) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - } - - async function attachAgent(sdk: OpencodeClient) { - if (!args.agent) return undefined - const name = args.agent - - const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined) - - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined - } - - const agent = modes.find((item) => item.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - - return name - } - - async function pickAgent(sdk: OpencodeClient) { - if (!args.agent) return undefined - if (args.attach) { - return attachAgent(sdk) - } - - return localAgent() - } - - async function execute(sdk: OpencodeClient) { - const sess = await session(sdk) - if (!sess?.id) { - UI.error("Session not found") - process.exit(1) - } - const sessionID = sess.id - - function emit(type: string, data: Record) { - if (args.format === "json") { - process.stdout.write( - JSON.stringify({ - type, - timestamp: Date.now(), - sessionID, - ...data, - }) + EOL, - ) - return true - } - return false - } - - // Consume one subscribed event stream for the active session and mirror it - // to stdout/UI. `client` is passed explicitly because attach mode may - // rebind the SDK to the session's directory after the subscription is - // created, and replies issued from inside the loop must use that client. - async function loop(client: OpencodeClient, events: Awaited>) { - const toggles = new Map() - let error: string | undefined - - for await (const event of events.stream) { - if ( - event.type === "message.updated" && - event.properties.sessionID === sessionID && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true - ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) - } - - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue - - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - await tool(part) - continue - } - await toolError(part) - UI.error(part.state.error) - } - - if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" - ) { - if (toggles.get(part.id) === true) continue - await tool(part) - toggles.set(part.id, true) - } - - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } - - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue - } - - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue - } - UI.empty() - UI.println(text) - UI.empty() - } - - if (part.type === "reasoning" && part.time?.end && thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue - } - process.stdout.write(line + EOL) - } - } - - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break - } - - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (auto) { - await client.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await client.permission.reply({ - requestID: permission.id, - reply: "reject", - }) - } - } - } - return error - } - const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) - const client = args.attach ? attachSDK(cwd) : sdk - - // Current-session flows resolve explicit --model refs from a location - // catalog that populates asynchronously after boot. Wait only when the - // user supplied a model; default-model races. - const selectedModel = pick(args.model) - if (selectedModel && (interactive || (currentPrompt && sess.current !== false))) { - await waitForCatalogReady({ sdk: client, directory: cwd, model: selectedModel }) - } - - // Validate agent if specified - const agent = await pickAgent(client) - - if (!interactive) { - if (currentPrompt && sess.current !== false) { - const model = selectedModel - const { runNonInteractivePrompt } = await import("./run/noninteractive") - try { - await runNonInteractivePrompt({ - client, - sessionID, - message, - files, - agent, - model, - variant: args.variant, - thinking, - format: args.format === "json" ? "json" : "default", - dangerouslySkipPermissions: args["dangerously-skip-permissions"], - attached: Boolean(args.attach), - renderTool: tool, - renderToolError: toolError, - }) - } catch (error) { - const output = error instanceof Error ? { type: "unknown", message: error.message } : error - if (!emit("error", { error: output })) UI.error(formatRunError(error)) - process.exitCode = 1 - } - return - } - - const events = await client.event.subscribe() - const completed = loop(client, events).catch((e) => { - console.error(e) - process.exitCode = 1 - }) - async function finish() { - if (args.attach) return - const error = await completed - if (error) process.exitCode = 1 - } - - if (args.command) { - const result = await client.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - if (result.error) { - if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) - process.exitCode = 1 - return - } - await finish() - return - } - - const model = pick(args.model) - const result = await client.session.prompt({ - sessionID, - agent, - model, - variant: args.variant, - parts: [...files, { type: "text", text: message }], - }) - if (result.error) { - if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) - process.exitCode = 1 - return - } - await finish() - return - } - - const model = pick(args.model) - const { runInteractiveMode } = await import("./run/runtime") - try { - await runInteractiveMode({ - sdk: client, - directory: cwd, - sessionID, - sessionTitle: sess.title, - resume: Boolean(args.session || args.continue), - replay, - replayLimit: args["replay-limit"], - agent, - model, - variant: args.variant, - files, - initialInput, - createSession: createFreshSession, - thinking, - backgroundSubagents: flags.experimentalBackgroundSubagents, - demo: args.demo, - }) - } catch (error) { - dieInteractive(error) - } - return - } - - if (interactive && !args.attach && !args.session && !args.continue) { - const model = pick(args.model) - const { runInteractiveLocalMode } = await import("./run/runtime") - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const { Server } = await import("@/server/server") - const request = new Request(input, init) - const headers = new Headers(request.headers) - const auth = ServerAuth.header() - if (auth) headers.set("Authorization", auth) - return Server.Default().app.fetch(new Request(request, { headers })) - }) as typeof globalThis.fetch - - try { - return await runInteractiveLocalMode({ - directory: directory ?? root, - fetch: fetchFn, - resolveAgent: localAgent, - session, - createSession: createFreshSession, - agent: args.agent, - model, - variant: args.variant, - replay, - replayLimit: args["replay-limit"], - files, - initialInput, - thinking, - backgroundSubagents: flags.experimentalBackgroundSubagents, - demo: args.demo, - }) - } catch (error) { - dieInteractive(error) - } - } - - if (args.attach) { - const sdk = attachSDK(directory) - return await execute(sdk) - } - - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const { Server } = await import("@/server/server") - const request = new Request(input, init) - const headers = new Headers(request.headers) - const auth = ServerAuth.header() - if (auth) headers.set("Authorization", auth) - return Server.Default().app.fetch(new Request(request, { headers })) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ - baseUrl: "http://opencode.internal", - fetch: fetchFn, - directory, - }) - await execute(sdk) - }) + const { runNonInteractive } = yield* Effect.promise(() => import("@opencode-ai/cli/mini")) + yield* Effect.promise(() => + runNonInteractive({ + message: [...args.message, ...(args["--"] || [])], + continue: args.continue, + session: args.session, + fork: args.fork, + model: args.model, + agent: args.agent, + format: args.format, + file: args.file ?? [], + title: args.title, + server: args.server ?? args.attach, + password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD, + username: args.username ?? process.env.OPENCODE_SERVER_USERNAME, + directory: args.dir, + variant: args.variant, + thinking: args.thinking, + dangerouslySkipPermissions: args.auto || args.yolo || args["dangerously-skip-permissions"], + standaloneCommand: args.server || args.attach ? undefined : v2ServerCommand(), + }), + ) }), }) - -type MiniCommandInput = { - directory?: string - attach?: string - password?: string - username?: string - continue?: boolean - session?: string - fork?: boolean - model?: string - agent?: string - prompt?: string - replay?: boolean - replayLimit?: number - demo?: boolean -} - -export async function runMini(input: MiniCommandInput) { - if (!RunCommand.handler) throw new Error("Mini command handler is unavailable") - await RunCommand.handler({ - $0: "opencode", - _: ["mini"], - message: input.prompt ? [input.prompt] : [], - command: undefined, - continue: input.continue, - session: input.session, - fork: input.fork, - model: input.model, - agent: input.agent, - format: "default", - file: undefined, - title: undefined, - attach: input.attach, - password: input.password, - username: input.username, - dir: input.directory, - port: undefined, - variant: undefined, - thinking: undefined, - mini: true, - interactive: false, - replay: input.replay ?? true, - "replay-limit": input.replayLimit, - replayLimit: input.replayLimit, - auto: false, - yolo: false, - "dangerously-skip-permissions": false, - dangerouslySkipPermissions: false, - demo: input.demo ?? false, - } as Parameters>[0] & { mini: boolean }) -} diff --git a/packages/opencode/src/cli/cmd/v2-serve.ts b/packages/opencode/src/cli/cmd/v2-serve.ts new file mode 100644 index 00000000000..e5eeb470d1e --- /dev/null +++ b/packages/opencode/src/cli/cmd/v2-serve.ts @@ -0,0 +1,31 @@ +import { ServerProcess } from "@opencode-ai/cli/server-process" +import { Effect } from "effect" +import { cmd } from "./cmd" + +export const V2ServeCommand = cmd({ + command: "__v2-serve", + describe: false, + builder: (yargs) => + yargs + .option("stdio", { type: "boolean", hidden: true }) + .option("port", { type: "number", hidden: true }), + handler: async (args) => { + const controller = new AbortController() + const interrupt = () => controller.abort() + process.once("SIGINT", interrupt) + process.once("SIGTERM", interrupt) + process.once("SIGHUP", interrupt) + try { + await Effect.runPromise( + ServerProcess.run({ mode: args.stdio ? "stdio" : "service", port: args.port }), + { signal: controller.signal }, + ) + } catch (error) { + if (!controller.signal.aborted) throw error + } finally { + process.off("SIGINT", interrupt) + process.off("SIGTERM", interrupt) + process.off("SIGHUP", interrupt) + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/v2-server-command.ts b/packages/opencode/src/cli/cmd/v2-server-command.ts new file mode 100644 index 00000000000..ef04997868c --- /dev/null +++ b/packages/opencode/src/cli/cmd/v2-server-command.ts @@ -0,0 +1,8 @@ +import path from "node:path" + +export function v2ServerCommand() { + const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" + const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : [] + if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint") + return [process.execPath, ...entrypoint, "__v2-serve"] +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index f17904bd49c..daa99d43c05 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -19,7 +19,7 @@ import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/attach" -import { MiniCommand } from "./cli/cmd/mini" +import { V2ServeCommand } from "./cli/cmd/v2-serve" import { TuiThreadCommand } from "./cli/cmd/tui" import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" @@ -81,7 +81,7 @@ const cli = yargs(args) .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) - .command(MiniCommand) + .command(V2ServeCommand) .command(TuiThreadCommand) .command(AttachCommand) .command(RunCommand) diff --git a/packages/opencode/src/temporary.ts b/packages/opencode/src/temporary.ts index b100ba9d109..8ece4b89ef6 100644 --- a/packages/opencode/src/temporary.ts +++ b/packages/opencode/src/temporary.ts @@ -1,6 +1,6 @@ import yargs from "yargs" -import { MiniCommand } from "./cli/cmd/mini" import { TuiThreadCommand } from "./cli/cmd/tui" +import { V2ServeCommand } from "./cli/cmd/v2-serve" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { hideBin } from "yargs/helpers" const cli = yargs(hideBin(process.argv)) @@ -28,6 +28,6 @@ const cli = yargs(hideBin(process.argv)) if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel }) - .command(MiniCommand) + .command(V2ServeCommand) .command(TuiThreadCommand) .parse() diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index bd5dc88e7e9..5c84f0d389e 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -41,34 +41,6 @@ Options: --pure run without external plugins [boolean]" `; -exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini --help 1`] = ` -"opencode mini - -start the minimal interactive interface - -Commands: - opencode mini [project] start the minimal interactive interface [default] - opencode mini attach attach to a running opencode server with the minimal interface - -Positionals: - project path to start opencode in [string] - -Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - -m, --model model to use in the format of provider/model [string] - --agent agent to use [string] - --prompt prompt to use [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - --no-replay disable session history replay on resume and after resize [boolean] - --replay-limit cap visible replay to the newest N messages [number]" -`; - exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = ` "opencode attach @@ -100,33 +72,25 @@ Positionals: message message to send [array] [default: []] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --command the command to run, use message for args [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session before continuing (requires --continue or --session) [boolean] - -m, --model model to use in the format of provider/model [string] - --agent agent to use [string] - --format format: default (formatted) or json (raw JSON events) + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session before continuing (requires --continue or --session) [boolean] + -m, --model model to use in the format of provider/model [string] + --agent agent to use [string] + --format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"] - -f, --file file(s) to attach to message [array] - --title title for the session (uses truncated prompt if no value provided) [string] - --attach attach to a running opencode server (e.g., http://localhost:4096) [string] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') - [string] - --dir directory to run in, path on remote server if attaching [string] - --port port for the local server (defaults to random port if no value provided) - [number] - --variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) - [string] - --thinking show thinking blocks [boolean] - -i, --interactive run in direct interactive split-footer mode [boolean] [default: false] - --auto auto-approve permissions that are not explicitly denied (dangerous!) + -f, --file file(s) to attach to message [array] + --title title for the session (uses truncated prompt if no value provided) [string] + --server connect to a running opencode server [string] + --dir directory to run in, or a path on the remote server [string] + --variant model variant [string] + --thinking show thinking blocks [boolean] + --auto auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false]" `; @@ -426,31 +390,6 @@ Options: --format Output format [string] [choices: "json", "tsv"] [default: "tsv"]" `; -exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini attach --help 1`] = ` -"opencode mini attach - -attach to a running opencode server with the minimal interface - -Positionals: - url http://localhost:4096 [string] [required] - -Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --dir directory on the remote server [string] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') - [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - --no-replay disable session history replay on resume and after resize [boolean] - --replay-limit cap visible replay to the newest N messages [number]" -`; - exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = ` "opencode mcp list diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index fc3dabeef32..ec3dd902cb9 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -57,7 +57,6 @@ function normalize(text: string): string { const TOP_LEVEL = [ "acp", "mcp", - "mini", "attach", "run", "debug", @@ -82,7 +81,6 @@ const TOP_LEVEL = [ // distinct argv shape, not every leaf. Add new entries when a subcommand // gains user-visible flags that we want to lock in. const SUBCOMMANDS = [ - ["mini", "attach"], ["mcp", "list"], ["mcp", "add"], ["mcp", "auth"], @@ -115,7 +113,7 @@ describe("opencode CLI help-text snapshots", () => { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) expect(topLevel.stderr.endsWith("\n")).toBe(true) - expect(topLevel.stderr).toContain("opencode mini") + expect(topLevel.stderr).not.toContain("opencode mini") expect(topLevel.stderr).not.toContain("--mini") expect(topLevel.stderr).not.toContain("--thinking") expect(topLevel.stderr).not.toContain("--variant") diff --git a/packages/opencode/test/cli/run/catalog.shared.test.ts b/packages/opencode/test/cli/run/catalog.shared.test.ts index 847eceebec7..0f5ef225bf2 100644 --- a/packages/opencode/test/cli/run/catalog.shared.test.ts +++ b/packages/opencode/test/cli/run/catalog.shared.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" -import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared" +import { OpenCode } from "@opencode-ai/client/promise" +import { loadRunReferences, runProviders, waitForDefaultModel } from "@opencode-ai/cli/mini/catalog.shared" afterEach(() => { mock.restore() @@ -8,20 +8,12 @@ afterEach(() => { describe("run catalog shared", () => { test("resolves the catalog-selected model for the footer", async () => { - const client = new OpencodeClient() - const selected = spyOn(client.v2.model, "default").mockImplementation( + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const selected = spyOn(client.model, "default").mockImplementation( () => Promise.resolve({ - data: { - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - data: { - id: "gpt-5", - providerID: "openai", - }, - }, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: { id: "gpt-5", providerID: "openai" }, }) as never, ) @@ -29,17 +21,16 @@ describe("run catalog shared", () => { providerID: "openai", modelID: "gpt-5", }) - expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) + expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }) }) test("loads visible project references from the current reference catalog", async () => { - const client = new OpencodeClient() - const list = spyOn(client.v2.reference, "list").mockImplementation( + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const list = spyOn(client.reference, "list").mockImplementation( () => Promise.resolve({ - data: { - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - data: [ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: [ { name: "effect", path: "/repos/effect", @@ -52,17 +43,13 @@ describe("run catalog shared", () => { hidden: true, source: { type: "local", path: "/repos/secret" }, }, - ], - }, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), + ], }) as never, ) const references = await loadRunReferences(client, "/tmp") - expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) + expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }) expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }]) }) diff --git a/packages/opencode/test/cli/run/entry.body.test.ts b/packages/opencode/test/cli/run/entry.body.test.ts index f393350381e..714d0eab254 100644 --- a/packages/opencode/test/cli/run/entry.body.test.ts +++ b/packages/opencode/test/cli/run/entry.body.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { ToolPart } from "@opencode-ai/sdk/v2" -import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body" -import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types" +import { entryBody, entryCanStream, entryDone } from "@opencode-ai/cli/mini/entry.body" +import type { StreamCommit, ToolSnapshot } from "@opencode-ai/cli/mini/types" function commit(input: Partial & Pick): StreamCommit { return input diff --git a/packages/opencode/test/cli/run/footer.menu.test.ts b/packages/opencode/test/cli/run/footer.menu.test.ts index edfa59be881..4086155acb9 100644 --- a/packages/opencode/test/cli/run/footer.menu.test.ts +++ b/packages/opencode/test/cli/run/footer.menu.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { createRoot } from "solid-js" -import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu" +import { FOOTER_MENU_ROWS, createFooterMenuState } from "@opencode-ai/cli/mini/footer.menu" function mount(count: number, limit = FOOTER_MENU_ROWS) { let dispose!: () => void diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index da9ce384b4c..4c3139ce9c7 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -15,10 +15,10 @@ import { RunSkillSelectBody, RunSubagentSelectBody, RunVariantSelectBody, -} from "@/cli/cmd/run/footer.command" -import { RunFooterView } from "@/cli/cmd/run/footer.view" -import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer" -import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme" +} from "@opencode-ai/cli/mini/footer.command" +import { RunFooterView } from "@opencode-ai/cli/mini/footer.view" +import { RunEntryContent } from "@opencode-ai/cli/mini/scrollback.writer" +import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme" import type { FooterState, FooterSubagentState, @@ -30,10 +30,10 @@ import type { RunProvider, RunTuiConfig, StreamCommit, -} from "@/cli/cmd/run/types" -import { RunQuestionBody } from "@/cli/cmd/run/footer.question" -import { selectedCommand } from "@/cli/cmd/run/footer.prompt" -import { RejectField } from "@/cli/cmd/run/footer.permission" +} from "@opencode-ai/cli/mini/types" +import { RunQuestionBody } from "@opencode-ai/cli/mini/footer.question" +import { selectedCommand } from "@opencode-ai/cli/mini/footer.prompt" +import { RejectField } from "@opencode-ai/cli/mini/footer.permission" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" const tuiConfig = createTuiResolvedConfig() diff --git a/packages/opencode/test/cli/run/footer.width.test.ts b/packages/opencode/test/cli/run/footer.width.test.ts index 75efcb8aaf4..8feabba6a8b 100644 --- a/packages/opencode/test/cli/run/footer.width.test.ts +++ b/packages/opencode/test/cli/run/footer.width.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { footerWidthPolicy } from "@/cli/cmd/run/footer.width" +import { footerWidthPolicy } from "@opencode-ai/cli/mini/footer.width" describe("run footer width", () => { test("preserves shared dialog and statusline breakpoints", () => { diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts index 4462c53a973..e77d28b62bd 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -1,16 +1,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2" -import { runNonInteractivePrompt } from "@/cli/cmd/run/noninteractive" +import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise" +import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive" +type V2Event = EventSubscribeOutput type FormInfo = Extract["data"]["form"] function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) + return Promise.resolve(data) } function form(id: string, sessionID: string): FormInfo { @@ -43,8 +39,8 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event { // Runs one non-interactive prompt against a mocked SDK. `turn` produces the // live events the prompt admission triggers, keyed by the generated message ID. async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) { - const sdk = new OpencodeClient() - const values: V2Event[] = [{ id: "evt_connected", created: 0, type: "server.connected", data: {} }] + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }] let wake: (() => void) | undefined const stream = (async function* (): AsyncGenerator { while (true) { @@ -58,22 +54,20 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: yield value } })() - spyOn(sdk.v2.event, "subscribe").mockImplementation( - () => Promise.resolve({ stream }) as ReturnType, - ) - spyOn(sdk.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }) as never) - spyOn(sdk.v2.session.question, "list").mockImplementation(() => ok({ data: [] }) as never) - spyOn(sdk.v2.session.form, "list").mockImplementation( + spyOn(sdk.event, "subscribe").mockImplementation(() => stream) + spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never) + spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never) + spyOn(sdk.form, "list").mockImplementation( (request) => - ok({ data: input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? [] }) as never, + ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, ) - spyOn(sdk.v2.session.form, "cancel").mockImplementation(() => ok(undefined) as never) - spyOn(sdk.v2.session, "prompt").mockImplementation((request) => { + spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never) + spyOn(sdk.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" values.push(...input.turn(messageID)) wake?.() wake = undefined - return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 } }) as never + return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never }) await runNonInteractivePrompt({ client: sdk, @@ -102,9 +96,9 @@ describe("runNonInteractivePrompt", () => { // which must not leave the consume loop waiting forever. turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")], }) - expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) - expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) - expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) }) test("attach mode cancels only session-owned forms", async () => { @@ -113,9 +107,9 @@ describe("runNonInteractivePrompt", () => { pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")], turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()], }) - expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) - expect(sdk.v2.session.form.list).not.toHaveBeenCalledWith({ sessionID: "global" }) - expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) - expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) + expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) + expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" }) + expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) + expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) }) }) diff --git a/packages/opencode/test/cli/run/permission.shared.test.ts b/packages/opencode/test/cli/run/permission.shared.test.ts index 03be4220f21..84ad63207cf 100644 --- a/packages/opencode/test/cli/run/permission.shared.test.ts +++ b/packages/opencode/test/cli/run/permission.shared.test.ts @@ -8,7 +8,7 @@ import { permissionInfo, permissionReject, permissionRun, -} from "@/cli/cmd/run/permission.shared" +} from "@opencode-ai/cli/mini/permission.shared" function req(input: Partial = {}): PermissionRequest { return { diff --git a/packages/opencode/test/cli/run/prompt.editor.test.ts b/packages/opencode/test/cli/run/prompt.editor.test.ts index 872c6030475..55062174795 100644 --- a/packages/opencode/test/cli/run/prompt.editor.test.ts +++ b/packages/opencode/test/cli/run/prompt.editor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor" -import type { RunPromptPart } from "@/cli/cmd/run/types" +import { realignEditorPromptParts, resolveEditorSlashValue } from "@opencode-ai/cli/mini/prompt.editor" +import type { RunPromptPart } from "@opencode-ai/cli/mini/types" describe("run prompt editor helpers", () => { test("strips the local /editor command from the initial editor text", () => { diff --git a/packages/opencode/test/cli/run/prompt.shared.test.ts b/packages/opencode/test/cli/run/prompt.shared.test.ts index fcb1bfe4254..e6da0b155ec 100644 --- a/packages/opencode/test/cli/run/prompt.shared.test.ts +++ b/packages/opencode/test/cli/run/prompt.shared.test.ts @@ -5,8 +5,8 @@ import { isNewCommand, movePromptHistory, pushPromptHistory, -} from "@/cli/cmd/run/prompt.shared" -import type { RunPrompt } from "@/cli/cmd/run/types" +} from "@opencode-ai/cli/mini/prompt.shared" +import type { RunPrompt } from "@opencode-ai/cli/mini/types" function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt { return { text, parts } diff --git a/packages/opencode/test/cli/run/question.shared.test.ts b/packages/opencode/test/cli/run/question.shared.test.ts index 27c8cecdb36..552fc9a65c4 100644 --- a/packages/opencode/test/cli/run/question.shared.test.ts +++ b/packages/opencode/test/cli/run/question.shared.test.ts @@ -10,7 +10,7 @@ import { questionStoreCustom, questionSubmit, questionSync, -} from "@/cli/cmd/run/question.shared" +} from "@opencode-ai/cli/mini/question.shared" function req(input: Partial = {}): QuestionRequest { return { diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 30ae27b68d9..6f4b01a8492 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -363,11 +363,11 @@ describe("opencode run (non-interactive subprocess)", () => { ) cliIt.concurrent( - "applies a variant to the configured default model", + "applies a variant to the selected model", ({ llm, opencode }) => Effect.gen(function* () { yield* llm.text("variant response") - const result = yield* opencode.spawn(["run", "--variant", "default", "use the default model"], { + const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], { config: { ...testProviderConfig(llm.url), model: "test/test-model" }, }) diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index 59a0d8e7c57..f642c47c087 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,17 +1,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" +import { OpenCode } from "@opencode-ai/client/promise" import type { Resolved } from "@opencode-ai/tui/config" -import { TuiConfig } from "@/config/tui" -import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot" +import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) + return Promise.resolve(data) } function provider(id: string, name: string) { @@ -108,23 +102,21 @@ describe("run runtime boot", () => { }) test("reads footer keybinds from resolved keybind config", async () => { - spyOn(TuiConfig, "get").mockResolvedValue( - config({ - leader: "ctrl+g", - bindings: { - commandList: ["ctrl+p"], - variantCycle: ["ctrl+t", "alt+t"], - interrupt: ["ctrl+c"], - historyPrevious: ["k"], - historyNext: ["j"], - inputClear: ["ctrl+l"], - inputSubmit: ["ctrl+s"], - inputNewline: ["alt+return"], - }, - }), - ) + const input = config({ + leader: "ctrl+g", + bindings: { + commandList: ["ctrl+p"], + variantCycle: ["ctrl+t", "alt+t"], + interrupt: ["ctrl+c"], + historyPrevious: ["k"], + historyNext: ["j"], + inputClear: ["ctrl+l"], + inputSubmit: ["ctrl+s"], + inputNewline: ["alt+return"], + }, + }) - const result = await resolveRunTuiConfig() + const result = await resolveRunTuiConfig(input) expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g") expect(result.leader_timeout).toBe(2000) @@ -139,9 +131,7 @@ describe("run runtime boot", () => { }) test("falls back to default tui keymap config when config load fails", async () => { - spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) - - const result = await resolveRunTuiConfig() + const result = await resolveRunTuiConfig(Promise.reject(new Error("boom"))) expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x") expect(result.leader_timeout).toBe(2000) @@ -157,31 +147,23 @@ describe("run runtime boot", () => { }) test("preserves disabled leader from resolved tui config", async () => { - spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" })) - - const result = await resolveRunTuiConfig() + const result = await resolveRunTuiConfig(config({ leader: "none" })) expect(result.keybinds.get("leader")).toEqual([]) }) test("reads diff style and falls back to auto", async () => { - spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" })) - await expect(resolveDiffStyle()).resolves.toBe("stacked") + await expect(resolveDiffStyle(config({ diff_style: "stacked" }))).resolves.toBe("stacked") - mock.restore() - spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) - await expect(resolveDiffStyle()).resolves.toBe("auto") + await expect(resolveDiffStyle(Promise.reject(new Error("boom")))).resolves.toBe("auto") }) test("loads v2 providers and models for model selector data", async () => { - const sdk = new OpencodeClient() + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const providers = [provider("openai", "OpenAI")] const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])] - // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. - // @ts-expect-error successful SDK response is valid for both modes at runtime - const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers })) - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models })) + const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never) + spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never) await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ providers: [ @@ -230,19 +212,15 @@ describe("run runtime boot", () => { directory: "/workspace", }, }, - { throwOnError: true }, ) }) test("loads context limits across v2 providers", async () => { - const sdk = new OpencodeClient() + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")] const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)] - // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers })) - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models })) + spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never) + spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never) await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ providers: [ diff --git a/packages/opencode/test/cli/run/runtime.queue.test.ts b/packages/opencode/test/cli/run/runtime.queue.test.ts index 558373a35d3..bdb80efc1df 100644 --- a/packages/opencode/test/cli/run/runtime.queue.test.ts +++ b/packages/opencode/test/cli/run/runtime.queue.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { runPromptQueue } from "@/cli/cmd/run/runtime.queue" -import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types" +import { runPromptQueue } from "@opencode-ai/cli/mini/runtime.queue" +import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@opencode-ai/cli/mini/types" function footer() { const prompts = new Set<(input: RunPrompt) => void>() diff --git a/packages/opencode/test/cli/run/runtime.stdin.test.ts b/packages/opencode/test/cli/run/runtime.stdin.test.ts index 7c4a10e82db..42e090e8934 100644 --- a/packages/opencode/test/cli/run/runtime.stdin.test.ts +++ b/packages/opencode/test/cli/run/runtime.stdin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Readable } from "node:stream" -import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin" +import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@opencode-ai/cli/mini/runtime.stdin" function stream(isTTY: boolean) { return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index c98a61b69bd..fcba5842fed 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" -import { runInteractiveMode } from "@/cli/cmd/run/runtime" -import type { FooterApi, FooterEvent, RunProvider } from "@/cli/cmd/run/types" +import { OpenCode } from "@opencode-ai/client/promise" +import { runInteractiveDeferredMode, runInteractiveMode } from "@opencode-ai/cli/mini/runtime" +import type { FooterApi, FooterEvent, RunProvider } from "@opencode-ai/cli/mini/types" const provider: RunProvider = { id: "openai", @@ -45,12 +45,7 @@ function defer() { } function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) + return Promise.resolve(data) } function footer(events: FooterEvent[] = []): FooterApi { @@ -110,16 +105,173 @@ afterEach(() => { }) describe("run interactive runtime", () => { + test("resolves the deferred session only after first paint", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const lifecycleStarted = defer() + const painted = defer() + const api = footer() + let resolved = 0 + api.idle = () => painted.promise + spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + + const task = runInteractiveDeferredMode( + { + sdk, + directory: "/tmp", + resolveAgent: async () => "build", + session: async () => { + resolved++ + api.close() + return { id: "ses-deferred", title: "Deferred" } + }, + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async () => { + lifecycleStarted.resolve() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + }, + ) + + await lifecycleStarted.promise + expect(resolved).toBe(0) + painted.resolve() + await task + expect(resolved).toBe(1) + }) + + test("restores deferred session history and model after first paint", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const lifecycleStarted = defer() + const painted = defer() + const events: FooterEvent[] = [] + const api = footer(events) + api.idle = () => painted.promise + const event = api.event + api.event = (value) => { + event(value) + if (value.type === "model") api.close() + } + spyOn(sdk.session, "get").mockImplementation( + () => + ok({ + id: "ses-resume", + projectID: "pro-1", + title: "Resume", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: "/tmp" }, + model: { providerID: "openai", id: "gpt-5", variant: "high" }, + }) as never, + ) + spyOn(sdk.message, "list").mockImplementation( + () => + ok({ + data: [{ id: "msg-user", type: "user", text: "previous prompt", time: { created: 1 } }], + cursor: {}, + }) as never, + ) + spyOn(sdk.provider, "list").mockImplementation( + () => + ok({ + location: { directory: "/tmp" }, + data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }], + }) as never, + ) + spyOn(sdk.model, "list").mockImplementation( + () => + ok({ + location: { directory: "/tmp" }, + data: [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + request: { headers: {}, body: {} }, + variants: [{ id: "high", settings: {}, headers: {}, body: {} }], + time: { released: 1 }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 128000, output: 8192 }, + }, + ], + }) as never, + ) + spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + + const task = runInteractiveDeferredMode( + { + sdk, + directory: "/tmp", + resolveAgent: async () => "build", + session: async () => ({ id: "ses-resume", title: "Resume", resume: true }), + agent: "build", + model: undefined, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async () => { + lifecycleStarted.resolve() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + }, + ) + + await lifecycleStarted.promise + expect(sdk.session.get).not.toHaveBeenCalled() + painted.resolve() + await task + + expect(events).toContainEqual({ + type: "history", + history: [{ text: "previous prompt", parts: [] }], + }) + expect(events).toContainEqual({ + type: "model", + model: "Little Frank · OpenAI · high", + selection: { providerID: "openai", modelID: "gpt-5" }, + }) + }) + test("waits for provider metadata before eager replay transport bootstrap", async () => { const providersStarted = defer() const providers = defer() const lifecycleModels: unknown[] = [] - const sdk = new OpencodeClient() - const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) - const legacyAgents = spyOn(sdk.app, "agents").mockRejectedValue(new Error("legacy agents should stay unused")) - const legacyCommands = spyOn(sdk.command, "list").mockRejectedValue(new Error("legacy commands should stay unused")) - spyOn(sdk.v2.provider, "list").mockImplementation(async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + spyOn(sdk.provider, "list").mockImplementation(async () => { providersStarted.resolve() await providers.promise return ok({ @@ -142,55 +294,56 @@ describe("run interactive runtime", () => { ], }) as never }) - spyOn(sdk.v2.model, "list").mockImplementation(() => - ok({ - location: { - directory: "/tmp", - }, - data: [ - { - id: "gpt-5", - providerID: "openai", - name: "Little Frank", - api: { - id: "openai", - type: "native", - settings: {}, - }, - capabilities: { - tools: true, - input: ["text"], - output: ["text"], - }, - request: { - headers: {}, - body: {}, - }, - variants: [], - time: { - released: 1, - }, - cost: [ - { - input: 0, - output: 0, - cache: { - read: 0, - write: 0, - }, - }, - ], - status: "active", - enabled: true, - limit: { - context: 128000, - output: 8192, - }, + spyOn(sdk.model, "list").mockImplementation( + () => + ok({ + location: { + directory: "/tmp", }, - ], - }) as never, + data: [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + api: { + id: "openai", + type: "native", + settings: {}, + }, + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + request: { + headers: {}, + body: {}, + }, + variants: [], + time: { + released: 1, + }, + cost: [ + { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + ], + status: "active", + enabled: true, + limit: { + context: 128000, + output: 8192, + }, + }, + ], + }) as never, ) - spyOn(sdk.v2.session, "messages").mockImplementation(() => + spyOn(sdk.message, "list").mockImplementation(() => ok({ data: [ { @@ -205,9 +358,9 @@ describe("run interactive runtime", () => { cursor: {}, }), ) - spyOn(sdk.v2.session, "get").mockImplementation(() => - ok({ - data: { + spyOn(sdk.session, "get").mockImplementation( + () => + ok({ id: "ses-1", projectID: "pro-1", title: "Session", @@ -232,13 +385,12 @@ describe("run interactive runtime", () => { providerID: "openai", id: "gpt-5", }, - }, - }), + }) as never, ) - spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) - spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) - spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) - spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) const task = runInteractiveMode( { @@ -296,13 +448,10 @@ describe("run interactive runtime", () => { expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }]) expect(transportProviders).toEqual([[provider]]) - expect(legacyProviders).not.toHaveBeenCalled() - expect(legacyAgents).not.toHaveBeenCalled() - expect(legacyCommands).not.toHaveBeenCalled() }) test("defers catalog-selected model resolution until after first paint", async () => { - const sdk = new OpencodeClient() + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const defaultStarted = defer() const releaseDefault = defer() const lifecycleStarted = defer() @@ -320,7 +469,7 @@ describe("run interactive runtime", () => { api.close() } - spyOn(sdk.v2.model, "default").mockImplementation(async () => { + spyOn(sdk.model, "default").mockImplementation(async () => { defaultRequested = true defaultStarted.resolve() await releaseDefault.promise @@ -329,24 +478,12 @@ describe("run interactive runtime", () => { data: { id: "gpt-5", providerID: "openai" }, }) as never }) - spyOn(sdk.v2.provider, "list").mockImplementation(() => - ok({ location: { directory: "/tmp" }, data: [] }) as never, - ) - spyOn(sdk.v2.model, "list").mockImplementation(() => - ok({ location: { directory: "/tmp" }, data: [] }) as never, - ) - spyOn(sdk.v2.agent, "list").mockImplementation(() => - ok({ location: { directory: "/tmp" }, data: [] }) as never, - ) - spyOn(sdk.v2.reference, "list").mockImplementation(() => - ok({ location: { directory: "/tmp" }, data: [] }) as never, - ) - spyOn(sdk.v2.command, "list").mockImplementation(() => - ok({ location: { directory: "/tmp" }, data: [] }) as never, - ) - spyOn(sdk.v2.skill, "list").mockImplementation(() => - ok({ location: { directory: "/tmp" }, data: [] }) as never, - ) + spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) const task = runInteractiveMode( { @@ -402,12 +539,12 @@ describe("run interactive runtime", () => { }) test("does not start deferred work after the footer closes", async () => { - const sdk = new OpencodeClient() + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const lifecycleStarted = defer() const painted = defer() const api = footer() api.idle = () => painted.promise - const defaultModel = spyOn(sdk.v2.model, "default") + const defaultModel = spyOn(sdk.model, "default") const task = runInteractiveMode( { @@ -444,8 +581,50 @@ describe("run interactive runtime", () => { expect(defaultModel).not.toHaveBeenCalled() }) + test("searches files through the V2 file API", async () => { + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) + const api = footer() + const find = spyOn(sdk.file, "find").mockImplementation( + () => + ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: [{ path: "src/index.ts", type: "file" }], + }) as never, + ) + + await runInteractiveMode( + { + sdk, + directory: "/tmp", + sessionID: "ses-files", + resume: false, + agent: "build", + model: undefined, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async (input) => { + await expect(input.findFiles("index")).resolves.toEqual(["src/index.ts"]) + api.close() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + }, + ) + + expect(find).toHaveBeenCalledWith({ query: "index", type: "file", location: { directory: "/tmp" } }) + }) + test("retains last-known-good state across failed coalesced refreshes and retries later", async () => { - const sdk = new OpencodeClient() + const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const refreshGate = defer() let providerCalls = 0 let modelCalls = 0 @@ -453,7 +632,7 @@ describe("run interactive runtime", () => { let referenceCalls = 0 const events: FooterEvent[] = [] const api = footer(events) - spyOn(sdk.v2.provider, "list").mockImplementation(async () => { + spyOn(sdk.provider, "list").mockImplementation(async () => { providerCalls++ if (providerCalls === 2) { await refreshGate.promise @@ -471,7 +650,7 @@ describe("run interactive runtime", () => { ], }) as never }) - spyOn(sdk.v2.model, "list").mockImplementation(() => { + spyOn(sdk.model, "list").mockImplementation(() => { modelCalls++ return ok({ location: { directory: "/tmp" }, @@ -484,9 +663,7 @@ describe("run interactive runtime", () => { capabilities: { tools: true, input: ["text"], output: ["text"] }, request: { headers: {}, body: {} }, variants: - modelCalls >= 4 - ? [] - : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }], + modelCalls >= 4 ? [] : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }], time: { released: 1 }, cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], status: "active", @@ -496,7 +673,7 @@ describe("run interactive runtime", () => { ], }) as never }) - spyOn(sdk.v2.agent, "list").mockImplementation(async () => { + spyOn(sdk.agent, "list").mockImplementation(async () => { agentCalls++ if (agentCalls === 2) throw new Error("agent refresh failed") return ok({ @@ -504,7 +681,7 @@ describe("run interactive runtime", () => { data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }], }) as never }) - spyOn(sdk.v2.reference, "list").mockImplementation(() => { + spyOn(sdk.reference, "list").mockImplementation(() => { referenceCalls++ return ok({ location: { directory: "/tmp" }, @@ -513,12 +690,10 @@ describe("run interactive runtime", () => { ], }) as never }) - spyOn(sdk.v2.command, "list").mockImplementation(() => - ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never, - ) - spyOn(sdk.v2.skill, "list").mockImplementation(() => - ok({ location: { directory: "/tmp" }, data: [] }) as never, + spyOn(sdk.command, "list").mockImplementation( + () => ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never, ) + spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) let finalProviders: RunProvider[] = [] let finalLimits: Record = {} let retainedProviders: RunProvider[] = [] diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 7406d36cbb1..abe40559ed8 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -2,9 +2,9 @@ import { afterEach, expect, test } from "bun:test" import type { ToolPart } from "@opencode-ai/sdk/v2" import { RGBA, SyntaxStyle } from "@opentui/core" import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing" -import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface" -import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme" -import type { StreamCommit } from "@/cli/cmd/run/types" +import { RunScrollbackStream } from "@opencode-ai/cli/mini/scrollback.surface" +import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme" +import type { StreamCommit } from "@opencode-ai/cli/mini/types" type ClaimedCommit = { snapshot: { diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index 14833569861..b1fa17df139 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { Event } from "@opencode-ai/sdk/v2" -import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data" -import type { StreamCommit } from "@/cli/cmd/run/types" +import { createSessionData, reduceSessionData } from "@opencode-ai/cli/mini/session-data" +import type { StreamCommit } from "@opencode-ai/cli/mini/types" function reduce(data: ReturnType, event: unknown, thinking = true) { return reduceSessionData({ diff --git a/packages/opencode/test/cli/run/session.shared.test.ts b/packages/opencode/test/cli/run/session.shared.test.ts index 102a53ba50c..2e098581acf 100644 --- a/packages/opencode/test/cli/run/session.shared.test.ts +++ b/packages/opencode/test/cli/run/session.shared.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" +import { OpenCode } from "@opencode-ai/client/promise" import { createSession, resolveCurrentSession, @@ -7,7 +7,7 @@ import { sessionVariant, type RunSession, type SessionMessages, -} from "@/cli/cmd/run/session.shared" +} from "@opencode-ai/cli/mini/session.shared" type Message = SessionMessages[number] type Part = Message["parts"][number] @@ -252,11 +252,10 @@ describe("run session shared", () => { }) test("restores current prompt history from stored text and file references", async () => { - const client = new OpencodeClient() - spyOn(client.v2.session, "messages").mockImplementation(() => + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + spyOn(client.message, "list").mockImplementation(() => Promise.resolve({ - data: { - data: [ + data: [ { id: "msg_prompt", type: "user", @@ -272,32 +271,20 @@ describe("run session shared", () => { agents: [], time: { created: 1 }, }, - ], - cursor: {}, - }, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), + ], + cursor: {}, }), ) - spyOn(client.v2.session, "get").mockImplementation(() => + spyOn(client.session, "get").mockImplementation(() => Promise.resolve({ - data: { - data: { - id: "ses_1", - title: "Session", - version: "dev", - projectID: "proj_1", - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - time: { created: 1, updated: 1 }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - model: { providerID: "openai", id: "gpt-5", variant: "high" }, - }, - }, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), + id: "ses_1", + title: "Session", + projectID: "proj_1", + location: { directory: "/tmp" }, + time: { created: 1, updated: 1 }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model: { providerID: "openai", id: "gpt-5", variant: "high" }, }), ) diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index cd43fda2de9..d191419e5c9 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "node:url" -import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2" -import { createSessionTransport } from "@/cli/cmd/run/stream-v2.transport" -import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types" +import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise" +import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport" +import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types" import { tmpdir } from "../../fixture/fixture" -type RunV2Event = V2Event +type RunV2Event = EventSubscribeOutput function feed() { const values: RunV2Event[] = [] @@ -41,16 +41,11 @@ function feed() { } function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) + return Promise.resolve(data) } function connected(id = "evt_connected") { - return { id, created: 0, type: "server.connected", data: {} } satisfies RunV2Event + return { id, type: "server.connected", data: {} } satisfies RunV2Event } function durable(sessionID: string, seq = 0, version = 1) { @@ -85,9 +80,7 @@ function footer() { return { api, commits, events } } -type SessionMessages = NonNullable< - Awaited>["data"] ->["data"][number][] +type SessionMessages = MessageListOutput["data"] function sdk(input: { streams: ReturnType[] @@ -95,15 +88,10 @@ function sdk(input: { messages?: Record sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }> }) { - const client = new OpencodeClient() + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) let subscription = 0 - spyOn(client.v2.event, "subscribe").mockImplementation( - () => - Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType< - typeof client.v2.event.subscribe - >, - ) - spyOn(client.v2.session, "messages").mockImplementation((request) => + spyOn(client.event, "subscribe").mockImplementation(() => input.streams[subscription++]?.stream ?? feed().stream) + spyOn(client.message, "list").mockImplementation((request) => ok({ data: input.messages?.[request.sessionID] ?? [ { @@ -118,14 +106,14 @@ function sdk(input: { cursor: {}, }), ) - spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] })) - spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] })) - spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} })) - spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined)) - spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) + spyOn(client.permission, "list").mockImplementation(() => ok([])) + spyOn(client.question, "list").mockImplementation(() => ok([])) + spyOn(client.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} })) + spyOn(client.session, "switchAgent").mockImplementation(() => ok(undefined)) + spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined)) // The generated methods have conditional return types for throwOnError; the // minimal shapes below are enough for family discovery and model fallback. - spyOn(client.v2.session, "list").mockImplementation((request) => { + spyOn(client.session, "list").mockImplementation((request) => { const parentID = request?.parentID return ok({ location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, @@ -139,7 +127,7 @@ function sdk(input: { ) ?? [], }) as never }) - spyOn(client.v2.model, "default").mockImplementation( + spyOn(client.model, "default").mockImplementation( () => ok({ location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, @@ -170,9 +158,7 @@ describe("V2 mini transport", () => { expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"]) let admitted = false - // The generated method has conditional return types for throwOnError; this mock represents the successful branch. - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true @@ -185,7 +171,7 @@ describe("V2 mini transport", () => { delivery: "steer" as const, timeCreated: 2, }, - }) + }) as never }) const turn = transport.runPromptTurn({ @@ -250,10 +236,8 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: Parameters[0] | undefined - // The generated method has conditional return types for throwOnError; this mock represents the successful branch. - // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((input) => { + let request: Parameters[0] | undefined + spyOn(client.session, "prompt").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ @@ -282,7 +266,7 @@ describe("V2 mini transport", () => { delivery: "steer" as const, timeCreated: 2, }, - }) + }) as never }) await transport.runPromptTurn({ @@ -344,10 +328,10 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: Parameters[0] | undefined + let request: Parameters[0] | undefined // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((input) => { + spyOn(client.session, "prompt").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ @@ -441,10 +425,10 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: Parameters[0] | undefined + let request: Parameters[0] | undefined // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((input) => { + spyOn(client.session, "prompt").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ @@ -561,7 +545,7 @@ describe("V2 mini transport", () => { }, }) let projected = false - spyOn(client.v2.session, "messages").mockImplementation(() => + spyOn(client.message, "list").mockImplementation(() => ok({ data: projected ? [ @@ -589,7 +573,7 @@ describe("V2 mini transport", () => { let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true @@ -632,7 +616,7 @@ describe("V2 mini transport", () => { return active }, }) - spyOn(client.v2.session, "messages").mockImplementation(() => + spyOn(client.message, "list").mockImplementation(() => ok({ data: projected ? [ @@ -661,7 +645,7 @@ describe("V2 mini transport", () => { let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true @@ -701,7 +685,7 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - spyOn(client.v2.session, "messages").mockImplementation(() => + spyOn(client.message, "list").mockImplementation(() => ok({ data: [ { @@ -745,7 +729,7 @@ describe("V2 mini transport", () => { const events = feed() events.push(connected()) const client = sdk({ streams: [events] }) - spyOn(client.v2.session, "messages").mockImplementation(() => + spyOn(client.message, "list").mockImplementation(() => ok({ data: [ { @@ -842,7 +826,7 @@ describe("V2 mini transport", () => { let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true @@ -850,7 +834,7 @@ describe("V2 mini transport", () => { data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, }) }) - const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) const turn = transport.runPromptTurn({ agent: undefined, @@ -886,21 +870,19 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - // The generated method has conditional return types for throwOnError; the test only needs the nested model field. - // @ts-expect-error minimal session shape is enough for this lookup - spyOn(client.v2.session, "get").mockImplementation(() => ok({ data: { model: undefined } })) - spyOn(client.v2.model, "default").mockImplementation( + spyOn(client.session, "get").mockImplementation(() => ok({ model: undefined }) as never) + spyOn(client.model, "default").mockImplementation( () => ok({ location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, data: { id: "gpt-5", providerID: "openai" }, }) as never, ) - const switched = spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) + const switched = spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined)) let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true @@ -938,7 +920,7 @@ describe("V2 mini transport", () => { expect(switched).toHaveBeenCalledWith( { sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } }, - expect.objectContaining({ throwOnError: true }), + { signal: undefined }, ) await transport.close() }) @@ -958,7 +940,7 @@ describe("V2 mini transport", () => { let admitted = false // The generated method has conditional return types for throwOnError; this mock represents the successful branch. // @ts-expect-error successful SDK response is valid for both modes at runtime - spyOn(client.v2.session, "prompt").mockImplementation((request) => { + spyOn(client.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" const prompt = request.prompt ?? { text: "" } admitted = true @@ -966,7 +948,7 @@ describe("V2 mini transport", () => { data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 }, }) }) - const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) const controller = new AbortController() const turn = transport.runPromptTurn({ agent: undefined, @@ -1014,8 +996,8 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: Parameters[0] | undefined - spyOn(client.v2.session, "shell").mockImplementation((input) => { + let request: Parameters[0] | undefined + spyOn(client.session, "shell").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ @@ -1094,7 +1076,7 @@ describe("V2 mini transport", () => { }) let started = false let aborted = false - spyOn(client.v2.session, "shell").mockImplementation( + spyOn(client.session, "shell").mockImplementation( (_input, options) => new Promise((_, reject) => { started = true @@ -1104,7 +1086,7 @@ describe("V2 mini transport", () => { }) }) as never, ) - const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined)) const turn = transport.runPromptTurn({ agent: undefined, @@ -1135,9 +1117,9 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: Parameters[0] | undefined + let request: Parameters[0] | undefined let complete!: () => void - spyOn(client.v2.session, "shell").mockImplementation((input) => { + spyOn(client.session, "shell").mockImplementation((input) => { request = input return new Promise((resolve) => { complete = resolve @@ -1414,8 +1396,8 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: Parameters[0] | undefined - spyOn(client.v2.session, "command").mockImplementation((input) => { + let request: Parameters[0] | undefined + spyOn(client.session, "command").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ @@ -1436,14 +1418,12 @@ describe("V2 mini transport", () => { }) }) return ok({ - data: { - admittedSeq: 1, - id: input.id ?? "msg_cmd", - sessionID: "ses_1", - prompt: { text: "evaluated template" }, - delivery: "steer" as const, - timeCreated: 2, - }, + admittedSeq: 1, + id: input.id ?? "msg_cmd", + sessionID: "ses_1", + prompt: { text: "evaluated template" }, + delivery: "steer" as const, + timeCreated: 2, }) }) @@ -1471,8 +1451,8 @@ describe("V2 mini transport", () => { delivery: "steer", }) // Selection rides the command payload; no separate client-side switch. - expect(client.v2.session.switchAgent).not.toHaveBeenCalled() - expect(client.v2.session.switchModel).not.toHaveBeenCalled() + expect(client.session.switchAgent).not.toHaveBeenCalled() + expect(client.session.switchModel).not.toHaveBeenCalled() await transport.close() }) @@ -1488,10 +1468,10 @@ describe("V2 mini transport", () => { limits: () => ({}), footer: ui.api, }) - let request: Parameters[0] | undefined - const command = spyOn(client.v2.session, "command") - const prompt = spyOn(client.v2.session, "prompt") - spyOn(client.v2.session, "skill").mockImplementation((input) => { + let request: Parameters[0] | undefined + const command = spyOn(client.session, "command") + const prompt = spyOn(client.session, "prompt") + spyOn(client.session, "skill").mockImplementation((input) => { request = input queueMicrotask(() => { events.push({ @@ -1551,7 +1531,7 @@ describe("V2 mini transport", () => { footer: ui.api, }) let sent = false - spyOn(client.v2.session, "skill").mockImplementation(() => { + spyOn(client.session, "skill").mockImplementation(() => { sent = true return ok(undefined) as never }) @@ -1721,20 +1701,18 @@ describe("V2 mini transport", () => { ], }, }) - spyOn(client.v2.session, "get").mockImplementation(() => + spyOn(client.session, "get").mockImplementation(() => ok({ - data: { - id: "ses_child", - parentID: "ses_1", - projectID: "proj_1", - agent: "explore", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "Find files", - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - }, - }), + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp" }, + }) as never, ) const ui = footer() const transport = await createSessionTransport({ @@ -1857,7 +1835,7 @@ describe("V2 mini transport", () => { const hydration = new Promise((resolve) => { releaseHydration = resolve }) - spyOn(client.v2.session, "messages").mockImplementation(async (request) => { + spyOn(client.message, "list").mockImplementation(async (request) => { if (request.sessionID === "ses_child") { childHydrating = true await hydration @@ -1927,7 +1905,7 @@ describe("V2 mini transport", () => { const retry = new Promise((resolve) => { releaseRetry = resolve }) - spyOn(client.v2.session, "messages").mockImplementation(async (request) => { + spyOn(client.message, "list").mockImplementation(async (request) => { if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} }) childRequests++ if (childRequests === 1) { @@ -2006,7 +1984,7 @@ describe("V2 mini transport", () => { const hydration = new Promise((resolve) => { releaseHydration = resolve }) - spyOn(client.v2.session, "messages").mockImplementation(async (request) => { + spyOn(client.message, "list").mockImplementation(async (request) => { if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} }) childHydrating = true await hydration @@ -2119,21 +2097,19 @@ describe("V2 mini transport", () => { const gate = new Promise((resolve) => { resolveGet = resolve }) - spyOn(client.v2.session, "get").mockImplementation(async () => { + spyOn(client.session, "get").mockImplementation(async () => { await gate return ok({ - data: { - id: "ses_child", - parentID: "ses_1", - projectID: "proj_1", - agent: "explore", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "Find files", - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - }, - }) + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp" }, + }) as never }) const ui = footer() const transport = await createSessionTransport({ @@ -2178,21 +2154,19 @@ describe("V2 mini transport", () => { const gate = new Promise((resolve) => { resolveGet = resolve }) - spyOn(client.v2.session, "get").mockImplementation(async () => { + spyOn(client.session, "get").mockImplementation(async () => { await gate return ok({ - data: { - id: "ses_child", - parentID: "ses_1", - projectID: "proj_1", - agent: "explore", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "Find files", - location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, - }, - }) + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp" }, + }) as never }) const ui = footer() const transport = await createSessionTransport({ @@ -2286,10 +2260,7 @@ describe("V2 mini transport", () => { footer: ui.api, }) const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) - expect(client.v2.session.list).toHaveBeenCalledWith( - { parentID: "ses_1", limit: 100, order: "desc" }, - { throwOnError: true }, - ) + expect(client.session.list).toHaveBeenCalledWith({ parentID: "ses_1", limit: 100, order: "desc" }) expect(states.at(-1)?.tabs).toMatchObject([ { sessionID: "ses_child_old", diff --git a/packages/opencode/test/cli/run/stream.test.ts b/packages/opencode/test/cli/run/stream.test.ts index e6b40dd9251..8e02b7cf481 100644 --- a/packages/opencode/test/cli/run/stream.test.ts +++ b/packages/opencode/test/cli/run/stream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { writeSessionOutput } from "@/cli/cmd/run/stream" -import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types" +import { writeSessionOutput } from "@opencode-ai/cli/mini/stream" +import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types" function footer() { const events: FooterEvent[] = [] diff --git a/packages/opencode/test/cli/run/theme.test.ts b/packages/opencode/test/cli/run/theme.test.ts index 4102dea1c9a..52b8fc64398 100644 --- a/packages/opencode/test/cli/run/theme.test.ts +++ b/packages/opencode/test/cli/run/theme.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core" -import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme" +import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@opencode-ai/cli/mini/theme" const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index e05c9888bbc..70e565670f5 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -10,9 +10,9 @@ import { formatModelLabel, pickVariant, resolveVariant, -} from "@/cli/cmd/run/variant.shared" -import type { SessionMessages } from "@/cli/cmd/run/session.shared" -import type { RunProvider } from "@/cli/cmd/run/types" +} from "@opencode-ai/cli/mini/variant.shared" +import type { SessionMessages } from "@opencode-ai/cli/mini/session.shared" +import type { RunProvider } from "@opencode-ai/cli/mini/types" import { testEffect } from "../../lib/effect" const model = { diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index a358eddb31c..6d359006408 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -4,7 +4,6 @@ import fs from "fs/promises" import path from "path" import yargs from "yargs" import { tmpdir } from "../../fixture/fixture" -import { MiniLocalCommand } from "../../../src/cli/cmd/mini" import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" import { cliIt } from "../../lib/cli-process" @@ -38,7 +37,7 @@ describe("tui thread", () => { await check(".") }) - test("resolves a relative mini project from PWD when cwd differs", async () => { + test("resolves a relative project from PWD when cwd differs", async () => { await using pwd = await tmpdir({ git: true }) await using cwd = await tmpdir({ git: true }) @@ -46,18 +45,6 @@ describe("tui thread", () => { expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) }) - test("parses supported mini --no-replay forms", async () => { - for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { - const args = await yargs([]) - .command({ ...MiniLocalCommand, handler: () => {} }) - .exitProcess(false) - .parse([option, "--replay-limit", "10"]) - - expect(args.replay === false || args.noReplay === true).toBe(true) - expect(args.replayLimit).toBe(10) - } - }) - test("preserves boolean negation for existing options", async () => { const args = await yargs([]) .command({ ...TuiThreadCommand, handler: () => {} }) @@ -85,24 +72,6 @@ describe("tui thread", () => { }), ) - cliIt.live("routes local sessions through mini", ({ opencode }) => - Effect.gen(function* () { - const result = yield* opencode.spawn(["mini"]) - - opencode.expectExit(result, 1) - expect(result.stderr).toContain("opencode mini requires a TTY stdout") - }), - ) - - cliIt.live("routes attached sessions through mini attach", ({ opencode }) => - Effect.gen(function* () { - const result = yield* opencode.spawn(["mini", "attach", "http://127.0.0.1:1"]) - - opencode.expectExit(result, 1) - expect(result.stderr).toContain("opencode mini requires a TTY stdout") - }), - ) - cliIt.live("rejects removed attach mini alias", ({ opencode }) => Effect.gen(function* () { const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts index 640f4d67ff8..625cce39198 100644 --- a/packages/protocol/src/groups/health.ts +++ b/packages/protocol/src/groups/health.ts @@ -4,7 +4,11 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" export const HealthGroup = HttpApiGroup.make("server.health") .add( HttpApiEndpoint.get("health.get", "/api/health", { - success: Schema.Struct({ healthy: Schema.Literal(true) }), + success: Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, + pid: Schema.Int.check(Schema.isGreaterThan(0)), + }), }).annotateMerge( OpenApi.annotations({ identifier: "v2.health.get", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5b9d40cad58..4d2cf1922c4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -15546,6 +15546,8 @@ export type V2HealthGetResponses = { */ 200: { healthy: true + version: string + pid: number } } diff --git a/packages/server/package.json b/packages/server/package.json index 6c5b3cb1b5f..3a14a250a9d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,6 +12,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/simulation": "workspace:*", diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index 60000b3fc00..a73b050aa71 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -1,7 +1,10 @@ +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => - handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })), + handlers.handle("health.get", () => + Effect.succeed({ healthy: true as const, version: InstallationVersion, pid: process.pid }), + ), ) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts new file mode 100644 index 00000000000..de6f4b328f5 --- /dev/null +++ b/packages/server/src/process.ts @@ -0,0 +1,64 @@ +export * as ServerProcess from "./process" + +import { NodeHttpClient, 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 { Project } from "@opencode-ai/core/project" +import { HealthGroup } from "@opencode-ai/protocol/groups/health" +import { Context, Effect, Layer, Option } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" +import { createServer } from "node:http" +import { ServerAuth } from "./auth" +import { createRoutes } from "./routes" + +export type Options = { + readonly hostname: string + readonly port: Option.Option + readonly password: string +} + +const ReadinessApi = HttpApi.make("readiness").add(HealthGroup) + +export const start = Effect.fn("ServerProcess.start")(function* (options: Options) { + if (!options.password) return yield* Effect.fail(new Error("Missing server password")) + const address = yield* listen(options) + yield* Effect.gen(function* () { + const client = yield* HttpApiClient.make(ReadinessApi, { + baseUrl: HttpServer.formatAddress(address), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader( + request, + "authorization", + ServerAuth.header({ username: "opencode", password: options.password }) ?? "", + ), + ), + }) + yield* client["server.health"]["health.get"]({}) + }).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)) + return address +}) + +function listen(options: Options) { + if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password) + const next = (port: number): ReturnType => + bind(options.hostname, port, options.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) { + const server = createServer() + return Layer.build( + HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( + Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), + Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), + ), + ).pipe( + Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), + Effect.map((context) => Context.get(context, HttpServer.HttpServer).address), + ) +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 12279e71158..fc1199dedcb 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -38,9 +38,11 @@ "./plugin/command-shim": "./src/plugin/command-shim.ts", "./parsers-config": "./src/parsers-config.ts", "./util/error": "./src/util/error.ts", + "./util/filetype": "./src/util/filetype.ts", "./util/locale": "./src/util/locale.ts", "./util/persistence": "./src/util/persistence.ts", "./util/record": "./src/util/record.ts", + "./util/session": "./src/util/session.ts", "./logo": "./src/logo.ts", "./ui/dialog": "./src/ui/dialog.tsx", "./ui/spinner": "./src/ui/spinner.ts",