Merge remote-tracking branch 'origin/v2' into nxl/vcs-plugin-api
# Conflicts: # packages/core/src/config.ts # packages/core/src/plugin/internal.ts # packages/core/src/project.ts # packages/core/test/project.test.ts # packages/plugin/src/v2/effect/context.ts # packages/plugin/src/v2/effect/index.ts
This commit is contained in:
commit
8ede176244
599 changed files with 83198 additions and 11394 deletions
|
|
@ -177,7 +177,7 @@ export function DialogCustomProvider(props: Props) {
|
|||
>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ const SettingsProvidersContent: Component = () => {
|
|||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ export const SettingsProvidersV2: Component = () => {
|
|||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
id="session.synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
|
|||
const refresh: string[] = []
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/new.ts",
|
||||
event: "add",
|
||||
|
|
@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
|
|||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/open.ts",
|
||||
event: "change",
|
||||
|
|
@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
|
|||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src",
|
||||
event: "change",
|
||||
|
|
@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
|
|||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: "src/file.ts",
|
||||
event: "change",
|
||||
|
|
@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
|
|||
|
||||
invalidateFromWatcher(
|
||||
{
|
||||
type: "file.watcher.updated",
|
||||
type: "filesystem.changed",
|
||||
properties: {
|
||||
file: ".git/index.lock",
|
||||
event: "change",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ type WatcherOps = {
|
|||
}
|
||||
|
||||
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||
if (event.type !== "file.watcher.updated") return
|
||||
if (event.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||
|
|
|
|||
|
|
@ -820,7 +820,7 @@ export default function Page() {
|
|||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
if (evt.details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
|
|
|
|||
|
|
@ -10,25 +10,46 @@
|
|||
"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",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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:*",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Commands } from "../commands"
|
|||
import { Runtime } from "../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ServiceConfig } from "../../services/service-config"
|
||||
import type { Transport } from "@opencode-ai/client/effect"
|
||||
|
||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||
|
||||
|
|
@ -61,7 +60,7 @@ export function rawRequest(input: readonly string[]) {
|
|||
}
|
||||
|
||||
function resolveRequest(
|
||||
transport: Transport,
|
||||
transport: Service.Transport,
|
||||
input: readonly string[],
|
||||
params: Record<string, string>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect, Option, Redacted } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import type { Transport } from "@opencode-ai/client/effect"
|
||||
import { Env } from "../../env"
|
||||
import { ServiceConfig } from "../../services/service-config"
|
||||
import { Standalone } from "../../services/standalone"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
|
@ -19,11 +19,29 @@ export default Runtime.handler(Commands, (input) =>
|
|||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
const transport = yield* Effect.gen(function* () {
|
||||
if (server !== undefined) {
|
||||
const password = process.env["OPENCODE_SERVER_PASSWORD"]
|
||||
return {
|
||||
const password = yield* Env.password
|
||||
const explicit = {
|
||||
url: server,
|
||||
headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined,
|
||||
} satisfies Transport
|
||||
headers: password
|
||||
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
|
||||
: undefined,
|
||||
} satisfies Service.Transport
|
||||
// Fail loudly before entering the TUI: an explicit server that is
|
||||
// unreachable or rejects auth should not present as reconnect churn.
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
|
||||
if (response.status === 401)
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
password
|
||||
? `Server at ${server} rejected the password`
|
||||
: `Server at ${server} requires a password; set OPENCODE_PASSWORD`,
|
||||
),
|
||||
)
|
||||
if (!response.ok)
|
||||
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
|
||||
return explicit
|
||||
}
|
||||
if (input.standalone) return yield* Standalone.transport()
|
||||
const options = yield* ServiceConfig.options()
|
||||
|
|
@ -45,6 +63,24 @@ export default Runtime.handler(Commands, (input) =>
|
|||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
: () => Promise.resolve(transport)
|
||||
yield* runTui(transport, { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, discover)
|
||||
// Restart the managed service in place; start() resolves once the
|
||||
// replacement is healthy and the reconnect loop reattaches on its own.
|
||||
// Only meaningful in service mode: --server is not ours to restart and a
|
||||
// standalone child cannot be respawned.
|
||||
const reload = serviceOptions
|
||||
? () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Service.stop(serviceOptions)
|
||||
yield* Service.start(serviceOptions)
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
: undefined
|
||||
yield* runTui(
|
||||
transport,
|
||||
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
discover,
|
||||
reload,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EOL } from "node:os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
|
||||
|
|
|
|||
35
packages/cli/src/commands/handlers/mini.ts
Normal file
35
packages/cli/src/commands/handlers/mini.ts
Normal file
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
31
packages/cli/src/commands/handlers/run.ts
Normal file
31
packages/cli/src/commands/handlers/run.ts
Normal file
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,145 +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 { Global } from "@opencode-ai/core/global"
|
||||
import { Context, FileSystem, Layer, Option, Schedule, Schema } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { createRoutes } from "@opencode-ai/server/routes"
|
||||
import { 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 { 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 = process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
const config = input.service ? yield* ServiceConfig.read() : {}
|
||||
const password = input.service
|
||||
? yield* ServiceConfig.password()
|
||||
: 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<number>()
|
||||
: 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.
|
||||
const RegistrationId = Schema.Struct({ id: Schema.optional(Schema.String) })
|
||||
const decodeRegistrationId = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrationId))
|
||||
|
||||
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 })
|
||||
yield* fs.writeFileString(
|
||||
temp,
|
||||
JSON.stringify({
|
||||
id,
|
||||
version: InstallationVersion,
|
||||
url: HttpServer.formatAddress(address),
|
||||
pid: process.pid,
|
||||
password: secret,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
yield* fs.rename(temp, file)
|
||||
const currentID = fs.readFileString(file).pipe(
|
||||
Effect.flatMap(decodeRegistrationId),
|
||||
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<void>((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<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
bind(hostname, port, password).pipe(
|
||||
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
const server = createServer()
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
||||
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node]))),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
|
||||
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { EOL } from "os"
|
||||
import { Option } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
|
|
|||
70
packages/cli/src/daemon.ts
Normal file
70
packages/cli/src/daemon.ts
Normal file
|
|
@ -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<string>
|
||||
}
|
||||
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<typeof attach>
|
||||
export function transport(options: SharedOptions): ReturnType<typeof shared>
|
||||
export function transport(options: Options): ReturnType<typeof attach> | ReturnType<typeof shared>
|
||||
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"
|
||||
15
packages/cli/src/env.ts
Normal file
15
packages/cli/src/env.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Config } from "effect"
|
||||
|
||||
// Every environment variable the CLI reads, in one place. Consumers yield
|
||||
// these instead of touching process.env so the full surface stays visible,
|
||||
// typed, and redacted where secret.
|
||||
|
||||
// The opencode server password: sent by clients connecting to an explicit
|
||||
// --server, and adopted by a manually run or standalone server. The legacy
|
||||
// name is still honored.
|
||||
export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
|
||||
Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")),
|
||||
Config.withDefault(undefined),
|
||||
)
|
||||
|
||||
export * as Env from "./env"
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { Effect, FileSystem, Scope } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import { Spec } from "./spec"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Updater } from "../services/updater"
|
||||
import { FileSystem, Scope } from "effect"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
|
|
@ -12,11 +11,21 @@ export type Input<Value> =
|
|||
? Input
|
||||
: never
|
||||
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
type RuntimeHandler = (
|
||||
input: unknown,
|
||||
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||
default: (input: Input<Node>) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
default: (
|
||||
input: Input<Node>,
|
||||
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||
type ProvidedCommand = Command.Command<
|
||||
string,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
|
||||
>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
? Loader<Node>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
|
||||
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
|
||||
readonly description?: string
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Layer, Logger, References } from "effect"
|
||||
import { NodeFileSystem, NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Effect, Layer, Logger, References } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
||||
|
|
@ -34,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"),
|
||||
|
|
@ -46,7 +45,12 @@ const Handlers = Runtime.handlers(Commands, {
|
|||
serve: () => import("./commands/handlers/serve"),
|
||||
})
|
||||
|
||||
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
|
||||
Effect.logInfo("cli starting", {
|
||||
version: InstallationVersion,
|
||||
channel: InstallationChannel,
|
||||
local: InstallationLocal,
|
||||
args: process.argv.slice(2),
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Updater.layer),
|
||||
|
|
@ -54,6 +58,6 @@ Effect.logInfo("cli starting", { version: InstallationVersion, channel: Installa
|
|||
Effect.provide(LoggingLayer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(0))),
|
||||
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
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<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["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) {
|
||||
function location(directory: string, workspace?: string) {
|
||||
return {
|
||||
location: {
|
||||
directory,
|
||||
workspace,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -89,47 +97,65 @@ 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
|
||||
workspace?: 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, input.workspace))
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
|
||||
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
|
||||
return (result.data?.data ?? []).map(runAgent)
|
||||
export async function waitForDefaultModel(input: {
|
||||
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.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 loadRunCommands(sdk: OpencodeClient, directory: string): Promise<RunCommand[]> {
|
||||
export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
|
||||
const result = await sdk.agent.list(location(directory))
|
||||
return result.data.map(runAgent)
|
||||
}
|
||||
|
||||
export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise<RunCommand[]> {
|
||||
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<RunReference[]> {
|
||||
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<RunReference[]> {
|
||||
const result = await sdk.reference.list(location(directory))
|
||||
return result.data.filter((reference) => !reference.hidden)
|
||||
}
|
||||
|
||||
export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise<RunProvider[]> {
|
||||
export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise<RunProvider[]> {
|
||||
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])
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -8,11 +8,11 @@
|
|||
import { pathToFileURL } from "bun"
|
||||
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { normalizePromptContent } from "@opencode-ai/tui/editor"
|
||||
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<boolean>
|
||||
width: Accessor<number>
|
||||
theme: Accessor<RunFooterTheme>
|
||||
history?: RunPrompt[]
|
||||
history?: Accessor<RunPrompt[]>
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onCycle: () => void
|
||||
onInterrupt: () => boolean
|
||||
|
|
@ -175,14 +175,18 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
|
|||
return { type: "pending" as const }
|
||||
}
|
||||
|
||||
if (!commands.some((item) => item.name === head.name)) {
|
||||
const item = commands.find((entry) => entry.name === head.name)
|
||||
if (!item) {
|
||||
return { type: "none" as const }
|
||||
}
|
||||
|
||||
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
|
||||
return {
|
||||
type: "command" as const,
|
||||
command: { name: head.name, arguments: head.arguments, ...(item.source ? { source: item.source } : {}) },
|
||||
}
|
||||
}
|
||||
|
||||
function selectedCommand(text: string, command: RunPrompt["command"]) {
|
||||
export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) {
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
|
|
@ -192,9 +196,14 @@ function selectedCommand(text: string, command: RunPrompt["command"]) {
|
|||
return
|
||||
}
|
||||
|
||||
// Bound drafts (e.g. the skill picker) may predate or omit the catalog
|
||||
// source; resolve it at submit time so routing never degrades to a plain
|
||||
// command for a skill entry.
|
||||
const source = command.source ?? commands?.find((item) => item.name === command.name)?.source
|
||||
return {
|
||||
name: command.name,
|
||||
arguments: head.arguments,
|
||||
...(source ? { source } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -293,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
|
||||
|
|
@ -1178,7 +1190,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
|||
return
|
||||
}
|
||||
|
||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands())
|
||||
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
||||
input.onExit()
|
||||
return
|
||||
|
|
@ -203,6 +203,8 @@ export class RunFooter implements FooterApi {
|
|||
private setSubagent: (next: FooterSubagentState) => void
|
||||
private queuedPrompts: Accessor<FooterQueuedPrompt[]>
|
||||
private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
|
||||
private history: Accessor<RunPrompt[]>
|
||||
private setHistory: Setter<RunPrompt[]>
|
||||
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<FooterQueuedPrompt[]>([])
|
||||
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,15 @@ 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)
|
||||
}
|
||||
|
||||
if (next.type === "turn.duration") {
|
||||
const current = this.currentModel()
|
||||
this.flush()
|
||||
|
|
@ -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<void>
|
||||
|
|
@ -781,6 +781,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
|||
command: {
|
||||
name,
|
||||
arguments: "",
|
||||
source: "skill",
|
||||
},
|
||||
})
|
||||
closePanel()
|
||||
7
packages/cli/src/mini/index.ts
Normal file
7
packages/cli/src/mini/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||
export {
|
||||
runNonInteractive,
|
||||
mergeInput as mergeNonInteractiveInput,
|
||||
pickRunModel,
|
||||
type RunCommandInput,
|
||||
} from "./run"
|
||||
234
packages/cli/src/mini/mini.ts
Normal file
234
packages/cli/src/mini/mini.ts
Normal file
|
|
@ -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<string>
|
||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
||||
}
|
||||
|
||||
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
||||
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<string | undefined> | 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<Transport> {
|
||||
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<string> {
|
||||
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<ReturnType<OpenCodeClient["agent"]["list"]>> | 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<Session> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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[]
|
||||
|
|
@ -33,6 +35,8 @@ type Input = {
|
|||
thinking: boolean
|
||||
format: "default" | "json"
|
||||
dangerouslySkipPermissions: boolean
|
||||
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
||||
attached: boolean
|
||||
renderTool: (part: ToolPart) => Promise<void>
|
||||
renderToolError: (part: ToolPart) => Promise<void>
|
||||
}
|
||||
|
|
@ -50,18 +54,21 @@ type ToolState = StartedPart & {
|
|||
provider?: unknown
|
||||
}
|
||||
|
||||
type V2Event = EventSubscribeOutput
|
||||
type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||
|
||||
// MCP elicitations are temporarily owned by the "global" sentinel instead of a real
|
||||
// session. An exclusive local process may treat them as this run's blockers; an
|
||||
// attached client must not cancel input that may belong to another session.
|
||||
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<V2Event>
|
||||
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<string, StartedPart>()
|
||||
const tools = new Map<string, ToolState>()
|
||||
let submitted = false
|
||||
|
|
@ -69,6 +76,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
let emittedError = false
|
||||
let questionRejected = false
|
||||
let permissionRejected = false
|
||||
let formCancelled = false
|
||||
let interrupted = false
|
||||
let admission: AbortController | undefined
|
||||
|
||||
|
|
@ -91,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<string> }) => {
|
||||
if (!input.dangerouslySkipPermissions) {
|
||||
permissionRejected = true
|
||||
UI.println(
|
||||
|
|
@ -100,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,
|
||||
|
|
@ -108,19 +116,30 @@ 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<FormRequest, "id" | "sessionID">) => {
|
||||
formCancelled = true
|
||||
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
|
||||
}
|
||||
|
||||
const consume = async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("Event stream disconnected during prompt execution")
|
||||
const next = await stream.next().catch((error) => {
|
||||
if (!emittedError) throw error
|
||||
return { done: true as const, value: undefined }
|
||||
})
|
||||
if (next.done) {
|
||||
if (emittedError) return
|
||||
throw new Error("Event stream disconnected during prompt execution")
|
||||
}
|
||||
const event = next.value
|
||||
|
||||
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
|
|
@ -131,26 +150,34 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
await rejectQuestion(event.data)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
event.type === "form.created" &&
|
||||
submitted &&
|
||||
(event.data.form.sessionID === input.sessionID ||
|
||||
(!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID))
|
||||
) {
|
||||
await cancelForm(event.data.form)
|
||||
continue
|
||||
}
|
||||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
||||
const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now()
|
||||
const time = toMillis("created" in event ? event.created : undefined)
|
||||
|
||||
if (event.type === "session.next.prompted") {
|
||||
if (event.data.messageID === messageID) {
|
||||
if (event.type === "session.prompt.promoted") {
|
||||
if (event.data.inputID === messageID) {
|
||||
promoted = true
|
||||
continue
|
||||
}
|
||||
if (promoted && event.data.delivery === "queue") return
|
||||
}
|
||||
if (
|
||||
event.type === "session.next.execution.settled" &&
|
||||
event.type === "session.execution.settled" &&
|
||||
event.data.outcome === "interrupted" &&
|
||||
(interrupted || permissionRejected || questionRejected)
|
||||
(interrupted || permissionRejected || questionRejected || formCancelled)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!promoted) continue
|
||||
|
||||
if (event.type === "session.next.step.started") {
|
||||
if (event.type === "session.step.started") {
|
||||
const part: StepStartPart = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
|
|
@ -166,11 +193,11 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.next.text.started") {
|
||||
if (event.type === "session.text.started") {
|
||||
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.text.ended") {
|
||||
if (event.type === "session.text.ended") {
|
||||
const started = starts.get(event.data.textID)
|
||||
const part: TextPart = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
|
|
@ -184,11 +211,11 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.next.reasoning.started") {
|
||||
if (event.type === "session.reasoning.started") {
|
||||
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.reasoning.ended" && input.thinking) {
|
||||
if (event.type === "session.reasoning.ended" && input.thinking) {
|
||||
const started = starts.get(event.data.reasoningID)
|
||||
const part: ReasoningPart = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
|
|
@ -213,7 +240,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.next.tool.input.started") {
|
||||
if (event.type === "session.tool.input.started") {
|
||||
tools.set(event.data.callID, {
|
||||
id: partID(event.id),
|
||||
timestamp: time,
|
||||
|
|
@ -223,12 +250,12 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.tool.input.ended") {
|
||||
if (event.type === "session.tool.input.ended") {
|
||||
const current = tools.get(event.data.callID)
|
||||
if (current) current.raw = event.data.text
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.tool.called") {
|
||||
if (event.type === "session.tool.called") {
|
||||
const current = tools.get(event.data.callID)
|
||||
tools.set(event.data.callID, {
|
||||
id: current?.id ?? partID(event.id),
|
||||
|
|
@ -241,7 +268,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.tool.success") {
|
||||
if (event.type === "session.tool.success") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const part: ToolPart = {
|
||||
id: current.id,
|
||||
|
|
@ -274,7 +301,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
if (!emit("tool_use", time, { part })) await input.renderTool(part)
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.tool.failed") {
|
||||
if (event.type === "session.tool.failed") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const part: ToolPart = {
|
||||
|
|
@ -305,7 +332,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.next.step.ended") {
|
||||
if (event.type === "session.step.ended") {
|
||||
const part: StepFinishPart = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
|
|
@ -319,19 +346,19 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
emit("step_finish", time, { part })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.step.failed") {
|
||||
if (interrupted || permissionRejected || questionRejected) continue
|
||||
if (event.type === "session.step.failed") {
|
||||
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.execution.settled") {
|
||||
if (event.data.outcome === "failure" && !emittedError && !questionRejected) {
|
||||
if (event.type === "session.execution.settled") {
|
||||
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
|
||||
if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message)
|
||||
if (!emit("error", time, { error })) UI.error(error.message)
|
||||
}
|
||||
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
|
||||
return
|
||||
|
|
@ -344,34 +371,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<void> | 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))
|
||||
|
|
@ -379,7 +403,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,
|
||||
|
|
@ -390,29 +414,34 @@ 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(() => {})
|
||||
if (interrupted) return undefined
|
||||
if (interrupted || emittedError) return undefined
|
||||
throw error
|
||||
})
|
||||
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] = 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),
|
||||
const [permissions, questions, forms] = await Promise.all([
|
||||
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.form.list({ sessionID }).catch(() => undefined),
|
||||
),
|
||||
),
|
||||
])
|
||||
await Promise.all([
|
||||
...(permissions?.data?.data ?? []).map(replyPermission),
|
||||
...(questions?.data?.data ?? []).map(rejectQuestion),
|
||||
...(permissions ?? []).map(replyPermission),
|
||||
...(questions ?? []).map(rejectQuestion),
|
||||
...forms.flatMap((response) => response ?? []).map(cancelForm),
|
||||
])
|
||||
await completed
|
||||
} finally {
|
||||
|
|
@ -428,11 +457,12 @@ function partID(eventID: string) {
|
|||
|
||||
function fallbackTool(event: {
|
||||
id: string
|
||||
data: { timestamp: number; assistantMessageID: string; callID: string }
|
||||
created: number
|
||||
data: { assistantMessageID: string; callID: string }
|
||||
}): ToolState {
|
||||
return {
|
||||
id: partID(event.id),
|
||||
timestamp: toMillis(event.data.timestamp),
|
||||
timestamp: toMillis(event.created),
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: "tool",
|
||||
input: {},
|
||||
|
|
@ -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
|
||||
311
packages/cli/src/mini/run.ts
Normal file
311
packages/cli/src/mini/run.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
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 } 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<string>
|
||||
}
|
||||
|
||||
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 function runNonInteractive(input: RunCommandInput) {
|
||||
return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error)))
|
||||
}
|
||||
|
||||
async function run(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 workspace = session?.location.workspaceID
|
||||
const explicitModel = parseModel(input.model)
|
||||
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
||||
const defaultModel =
|
||||
!explicitModel && !sessionModel
|
||||
? await client.model
|
||||
.default({ location: { directory: cwd, workspace } })
|
||||
.then((result) =>
|
||||
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
|
||||
)
|
||||
: 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, workspace, model })
|
||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
||||
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 ? "..." : ""),
|
||||
})
|
||||
}
|
||||
|
||||
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) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
|
||||
}
|
||||
|
||||
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<FilePart> {
|
||||
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 {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -26,10 +24,10 @@ export type ModelInfo = {
|
|||
export type SessionInfo = {
|
||||
first: boolean
|
||||
history: RunPrompt[]
|
||||
model?: NonNullable<RunInput["model"]>
|
||||
variant: string | undefined
|
||||
}
|
||||
|
||||
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
|
||||
type BootService = {
|
||||
readonly resolveModelInfo: (
|
||||
sdk: RunInput["sdk"],
|
||||
|
|
@ -41,18 +39,10 @@ type BootService = {
|
|||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
) => Effect.Effect<SessionInfo>
|
||||
readonly resolveRunTuiConfig: () => Effect.Effect<RunTuiConfig>
|
||||
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
|
||||
}
|
||||
|
||||
const configTask: { current?: Promise<Config> } = {}
|
||||
|
||||
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
|
||||
|
||||
function loadConfig() {
|
||||
return reusePendingTask(configTask, () => TuiConfig.get())
|
||||
}
|
||||
|
||||
function emptyModelInfo(): ModelInfo {
|
||||
return {
|
||||
providers: [],
|
||||
|
|
@ -76,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,
|
||||
|
|
@ -141,23 +117,14 @@ const layer = Layer.effect(
|
|||
return {
|
||||
first: session.first,
|
||||
history: sessionHistory(session),
|
||||
variant: pickVariant(model, session),
|
||||
model: session.model,
|
||||
variant: pickVariant(model ?? session.model, session),
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -174,6 +141,10 @@ export async function resolveModelInfo(
|
|||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo())
|
||||
}
|
||||
|
||||
export function resolveModelInfoStrict(sdk: RunInput["sdk"], directory: string, model: RunInput["model"]) {
|
||||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model))
|
||||
}
|
||||
|
||||
// Fetches session messages to determine if this is the first turn and build prompt history.
|
||||
export async function resolveSessionInfo(
|
||||
sdk: RunInput["sdk"],
|
||||
|
|
@ -184,10 +155,12 @@ export async function resolveSessionInfo(
|
|||
}
|
||||
|
||||
// Reads TUI config once for direct mode keymap setup and display preferences.
|
||||
export async function resolveRunTuiConfig(): Promise<RunTuiConfig> {
|
||||
return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig())
|
||||
export async function resolveRunTuiConfig(
|
||||
config?: RunTuiConfig | Promise<RunTuiConfig>,
|
||||
): Promise<RunTuiConfig> {
|
||||
return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig())
|
||||
}
|
||||
|
||||
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
|
||||
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
|
||||
export async function resolveDiffStyle(config?: RunTuiConfig | Promise<RunTuiConfig>): Promise<RunDiffStyle> {
|
||||
return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto")
|
||||
}
|
||||
|
|
@ -12,10 +12,9 @@ import path from "path"
|
|||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { openEditor } from "@opencode-ai/tui/editor"
|
||||
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
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"
|
||||
|
|
@ -64,7 +63,7 @@ export type LifecycleInput = {
|
|||
agent: string | undefined
|
||||
model: RunInput["model"]
|
||||
variant: string | undefined
|
||||
tuiConfig: RunTuiConfig
|
||||
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
|
||||
backgroundSubagents: boolean
|
||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
||||
|
|
@ -108,7 +107,7 @@ function shutdown(renderer: CliRenderer): void {
|
|||
}
|
||||
|
||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
||||
if (title && !SessionApi.isDefaultTitle(title)) {
|
||||
if (title && !isDefaultTitle(title)) {
|
||||
return {
|
||||
title,
|
||||
showSession: true,
|
||||
|
|
@ -124,17 +123,9 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
|||
|
||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
||||
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
||||
|
||||
if (!input.model) {
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: "Model default",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentLabel,
|
||||
modelLabel: formatModelLabel(input.model, input.variant),
|
||||
modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +167,7 @@ function queueSplash(
|
|||
// the entry splash, RunFooter takes over the footer region.
|
||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||
const source = resolveInteractiveStdin()
|
||||
const footerTask = import("./footer")
|
||||
let unregisterKeymap: (() => void) | undefined
|
||||
|
||||
try {
|
||||
|
|
@ -194,10 +186,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const theme = await resolveRunTheme(renderer)
|
||||
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
|
||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
exit: false,
|
||||
|
|
@ -212,7 +204,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
model: input.model,
|
||||
variant: input.variant,
|
||||
})
|
||||
const footerTask = import("./footer")
|
||||
const wrote = queueSplash(
|
||||
renderer,
|
||||
state,
|
||||
|
|
@ -244,9 +235,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
theme,
|
||||
wrote,
|
||||
keymap,
|
||||
tuiConfig: input.tuiConfig,
|
||||
tuiConfig,
|
||||
backgroundSubagents: input.backgroundSubagents,
|
||||
diffStyle: input.tuiConfig.diff_style ?? "auto",
|
||||
diffStyle: tuiConfig.diff_style ?? "auto",
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
onQuestionReply: input.onQuestionReply,
|
||||
onQuestionReject: input.onQuestionReject,
|
||||
|
|
@ -260,6 +251,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||
return
|
||||
}
|
||||
|
||||
const { openEditor } = await import("@opencode-ai/tui/editor")
|
||||
await renderer.idle().catch(() => {})
|
||||
const ignore = () => {}
|
||||
detachSigint()
|
||||
|
|
@ -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<void> {
|
|||
? 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<void> {
|
|||
!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]
|
||||
|
|
@ -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,16 +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 { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
|
||||
import { createRunDemo } from "./demo"
|
||||
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||
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"
|
||||
|
|
@ -45,9 +51,7 @@ type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promis
|
|||
type RunRuntimeInput = {
|
||||
boot: () => Promise<BootContext>
|
||||
afterPaint?: (ctx: BootContext) => Promise<void> | void
|
||||
resolveSession?: (
|
||||
ctx: BootContext,
|
||||
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
|
||||
resolveSession?: (ctx: BootContext) => Promise<ResolvedSession>
|
||||
createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
|
|
@ -56,13 +60,14 @@ type RunRuntimeInput = {
|
|||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: RunInput["demo"]
|
||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
||||
}
|
||||
|
||||
type RunLocalInput = {
|
||||
type RunDeferredInput = {
|
||||
sdk: RunInput["sdk"]
|
||||
directory: string
|
||||
fetch: typeof globalThis.fetch
|
||||
resolveAgent: () => Promise<string | undefined>
|
||||
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"]
|
||||
|
|
@ -74,6 +79,7 @@ type RunLocalInput = {
|
|||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: RunInput["demo"]
|
||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
||||
}
|
||||
|
||||
type StreamTransportModule = Pick<
|
||||
|
|
@ -91,10 +97,13 @@ type StreamState = {
|
|||
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
|
||||
}
|
||||
|
||||
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
|
||||
|
||||
type ResolvedSession = {
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
agent?: string | undefined
|
||||
resume?: boolean
|
||||
}
|
||||
|
||||
function createSessionResolver(fn?: CreateSession) {
|
||||
|
|
@ -130,7 +139,7 @@ type RuntimeState = {
|
|||
sessionTitle?: string
|
||||
agent: string | undefined
|
||||
switching?: Promise<void>
|
||||
demo?: ReturnType<typeof createRunDemo>
|
||||
demo?: RunDemo
|
||||
selectSubagent?: (sessionID: string | undefined) => void
|
||||
session?: Promise<void>
|
||||
stream?: Promise<StreamState>
|
||||
|
|
@ -164,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)
|
||||
}
|
||||
|
||||
|
|
@ -179,23 +188,23 @@ async function resolveExitTitle(
|
|||
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
|
||||
const start = performance.now()
|
||||
const log = trace()
|
||||
const tuiConfigTask = resolveRunTuiConfig()
|
||||
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig)
|
||||
const ctx = await input.boot()
|
||||
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
|
||||
const sessionTask =
|
||||
ctx.resume === true
|
||||
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
|
||||
: Promise.resolve({
|
||||
first: true,
|
||||
history: [],
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
})
|
||||
const savedTask = resolveSavedVariant(ctx.model)
|
||||
const [tuiConfig, session, savedVariant] = await Promise.all([tuiConfigTask, sessionTask, savedTask])
|
||||
const [session, savedVariant] = await Promise.all([sessionTask, savedTask])
|
||||
const state: RuntimeState = {
|
||||
shown: !session.first,
|
||||
aborting: false,
|
||||
model: ctx.model,
|
||||
model: ctx.model ?? session.model,
|
||||
providers: [],
|
||||
variants: [],
|
||||
limits: {},
|
||||
|
|
@ -206,29 +215,49 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
sessionTitle: ctx.sessionTitle,
|
||||
agent: ctx.agent,
|
||||
}
|
||||
const ensureSession = () => {
|
||||
if (!input.resolveSession || state.sessionID) {
|
||||
return Promise.resolve()
|
||||
const loadModel = async () => {
|
||||
if (state.model) {
|
||||
return {
|
||||
model: state.model,
|
||||
savedVariant,
|
||||
boot: true,
|
||||
info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model),
|
||||
}
|
||||
}
|
||||
|
||||
if (state.session) {
|
||||
return state.session
|
||||
}
|
||||
|
||||
state.session = input.resolveSession(ctx).then((next) => {
|
||||
state.sessionID = next.sessionID
|
||||
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
|
||||
state.agent = next.agent
|
||||
const model = await waitForDefaultModel({
|
||||
sdk: ctx.sdk,
|
||||
directory: ctx.directory,
|
||||
active: () => !footer.isClosed,
|
||||
})
|
||||
return state.session
|
||||
}
|
||||
if (footer.isClosed) return
|
||||
const [fallbackSavedVariant, info] = await Promise.all([
|
||||
resolveSavedVariant(model),
|
||||
resolveModelInfo(ctx.sdk, ctx.directory, model),
|
||||
])
|
||||
if (!model || state.model) {
|
||||
return {
|
||||
model: state.model,
|
||||
savedVariant: undefined,
|
||||
boot: false,
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
state.model = model
|
||||
return {
|
||||
model,
|
||||
savedVariant: fallbackSavedVariant,
|
||||
boot: true,
|
||||
info,
|
||||
}
|
||||
}
|
||||
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: [],
|
||||
|
|
@ -236,11 +265,11 @@ 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,
|
||||
tuiConfig,
|
||||
tuiConfig: tuiConfigTask,
|
||||
backgroundSubagents: input.backgroundSubagents,
|
||||
onPermissionReply: async (next) => {
|
||||
if (state.demo?.permission(next)) {
|
||||
|
|
@ -248,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) => {
|
||||
|
|
@ -266,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) {
|
||||
|
|
@ -345,9 +374,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
}
|
||||
|
||||
state.aborting = true
|
||||
void (state.stream
|
||||
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }))
|
||||
void (
|
||||
state.stream
|
||||
? state.stream.then((item) => item.handle.interruptActiveTurn())
|
||||
: ctx.sdk.session.interrupt({ sessionID: state.sessionID })
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
state.aborting = false
|
||||
|
|
@ -360,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)
|
||||
|
|
@ -374,49 +405,148 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
},
|
||||
})
|
||||
const footer = shell.footer
|
||||
const firstPaint = footer.idle().catch(() => {})
|
||||
const ensureSession = () => {
|
||||
if (!input.resolveSession || state.sessionID) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (state.session) {
|
||||
return state.session
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const loadCatalog = async (): Promise<void> => {
|
||||
const applyCatalog = (catalog: {
|
||||
agents: Awaited<ReturnType<typeof loadRunAgents>>
|
||||
references: Awaited<ReturnType<typeof loadRunReferences>>
|
||||
commands: Awaited<ReturnType<typeof loadRunCommands>>
|
||||
}) => {
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
const [agents, references, commands] = await Promise.all([
|
||||
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
|
||||
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
|
||||
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
|
||||
])
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "catalog",
|
||||
agents,
|
||||
references,
|
||||
commands,
|
||||
agents: catalog.agents,
|
||||
references: catalog.references,
|
||||
commands: catalog.commands,
|
||||
})
|
||||
}
|
||||
|
||||
void footer
|
||||
.idle()
|
||||
.then(loadCatalog)
|
||||
.catch(() => {})
|
||||
const fetchCatalog = async () => {
|
||||
const [agents, references, commands] = await Promise.all([
|
||||
loadRunAgents(ctx.sdk, ctx.directory),
|
||||
loadRunReferences(ctx.sdk, ctx.directory),
|
||||
loadRunCommands(ctx.sdk, ctx.directory),
|
||||
])
|
||||
return { agents, references, commands }
|
||||
}
|
||||
|
||||
const loadCatalog = async () => {
|
||||
applyCatalog(
|
||||
await Promise.all([
|
||||
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
|
||||
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
|
||||
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
|
||||
]).then(([agents, references, commands]) => ({ agents, references, commands })),
|
||||
)
|
||||
}
|
||||
|
||||
const applyModelInfo = (
|
||||
info: Awaited<ReturnType<typeof resolveModelInfo>>,
|
||||
current: string | undefined,
|
||||
boot = false,
|
||||
saved = savedVariant,
|
||||
) => {
|
||||
state.providers = info.providers
|
||||
state.variants = variantsFor(state.providers, state.model)
|
||||
state.limits = info.limits
|
||||
state.activeVariant = boot
|
||||
? resolveVariant(ctx.variant, current, saved, state.variants)
|
||||
: current && !state.variants.includes(current)
|
||||
? undefined
|
||||
: current
|
||||
if (footer.isClosed) return
|
||||
footer.event({ type: "models", providers: info.providers })
|
||||
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
|
||||
if (state.model)
|
||||
footer.event({
|
||||
type: "model",
|
||||
model: formatModelLabel(state.model, state.activeVariant, state.providers),
|
||||
selection: state.model,
|
||||
})
|
||||
}
|
||||
|
||||
let catalogRefresh: Promise<void> | undefined
|
||||
let catalogRefreshQueued = false
|
||||
const requestCatalogRefresh = () => {
|
||||
catalogRefreshQueued = true
|
||||
if (catalogRefresh || footer.isClosed) return
|
||||
catalogRefresh = (async () => {
|
||||
await Promise.all([modelTask, initialCatalog])
|
||||
while (catalogRefreshQueued && !footer.isClosed) {
|
||||
catalogRefreshQueued = false
|
||||
const [catalog, info] = await Promise.allSettled([
|
||||
fetchCatalog(),
|
||||
resolveModelInfoStrict(ctx.sdk, ctx.directory, state.model),
|
||||
])
|
||||
if (catalog.status === "fulfilled") applyCatalog(catalog.value)
|
||||
if (info.status === "fulfilled") applyModelInfo(info.value, state.activeVariant)
|
||||
}
|
||||
})().finally(() => {
|
||||
catalogRefresh = undefined
|
||||
if (catalogRefreshQueued) requestCatalogRefresh()
|
||||
})
|
||||
void catalogRefresh.catch(() => {})
|
||||
}
|
||||
|
||||
const initialCatalog = firstPaint.then(() => (footer.isClosed ? undefined : loadCatalog())).catch(() => {})
|
||||
void initialCatalog
|
||||
|
||||
if (Flag.OPENCODE_SHOW_TTFD) {
|
||||
footer.append({
|
||||
kind: "system",
|
||||
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
|
||||
phase: "final",
|
||||
source: "system",
|
||||
void firstPaint.then(() => {
|
||||
if (footer.isClosed) return
|
||||
footer.append({
|
||||
kind: "system",
|
||||
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
|
||||
phase: "final",
|
||||
source: "system",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (input.demo) {
|
||||
await ensureSession()
|
||||
state.demo = createRunDemo({
|
||||
const createDemo = async () => {
|
||||
const { createRunDemo } = await import("./demo")
|
||||
return createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
|
|
@ -424,37 +554,35 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
})
|
||||
}
|
||||
|
||||
if (input.afterPaint) {
|
||||
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
|
||||
if (input.demo) {
|
||||
await firstPaint
|
||||
if (!footer.isClosed) {
|
||||
await ensureSession()
|
||||
state.demo = await createDemo()
|
||||
}
|
||||
}
|
||||
|
||||
void modelTask.then((info) => {
|
||||
state.providers = info.providers
|
||||
state.variants = variantsFor(state.providers, state.model)
|
||||
state.limits = info.limits
|
||||
if (input.afterPaint) {
|
||||
void firstPaint.then(() => (footer.isClosed ? undefined : input.afterPaint?.(ctx))).catch(() => {})
|
||||
}
|
||||
|
||||
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
|
||||
if (next !== state.activeVariant) {
|
||||
state.activeVariant = next
|
||||
}
|
||||
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({ type: "models", providers: info.providers })
|
||||
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
|
||||
if (!state.model) {
|
||||
return
|
||||
}
|
||||
|
||||
footer.event({
|
||||
type: "model",
|
||||
model: formatModelLabel(state.model, state.activeVariant, state.providers),
|
||||
})
|
||||
void modelTask.then((result) => {
|
||||
if (!result) return
|
||||
const current = state.model
|
||||
const boot =
|
||||
result.boot &&
|
||||
!!current &&
|
||||
current.providerID === result.model?.providerID &&
|
||||
current.modelID === result.model.modelID
|
||||
applyModelInfo(result.info, boot ? session.variant : state.activeVariant, boot, result.savedVariant)
|
||||
})
|
||||
|
||||
const streamTask = deps.streamTransport ?? import("./stream-v2.transport")
|
||||
let streamTask = deps.streamTransport
|
||||
const loadStreamTransport = () => {
|
||||
if (streamTask) return streamTask
|
||||
streamTask = import("./stream-v2.transport")
|
||||
return streamTask
|
||||
}
|
||||
const ensureStream = () => {
|
||||
if (state.stream) {
|
||||
return state.stream
|
||||
|
|
@ -468,7 +596,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
const mod = await streamTask
|
||||
const mod = await loadStreamTransport()
|
||||
if (footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
|
@ -484,6 +612,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
providers: () => state.providers,
|
||||
footer,
|
||||
trace: log,
|
||||
onCatalogRefresh: requestCatalogRefresh,
|
||||
})
|
||||
if (footer.isClosed) {
|
||||
await handle.close()
|
||||
|
|
@ -536,6 +665,12 @@ 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()
|
||||
|
|
@ -581,14 +716,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
state.history = []
|
||||
state.localRows = []
|
||||
includeFiles = true
|
||||
state.demo = input.demo
|
||||
? createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
})
|
||||
: undefined
|
||||
state.demo = input.demo ? await createDemo() : undefined
|
||||
log?.write("session.new", {
|
||||
sessionID: state.sessionID,
|
||||
})
|
||||
|
|
@ -631,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)
|
||||
|
|
@ -665,7 +793,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
||||
)
|
||||
}
|
||||
includeFiles = false
|
||||
// Shell and skill turns never send CLI file attachments; keep them
|
||||
// pending for the next prompt-shaped turn.
|
||||
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
|
||||
} catch (error) {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
return
|
||||
|
|
@ -691,6 +821,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
try {
|
||||
const eager = eagerStream(input, ctx)
|
||||
if (eager) {
|
||||
await firstPaint
|
||||
if (footer.isClosed) return
|
||||
if (input.replay && state.shown) {
|
||||
// Replay commits immutable scrollback rows, so wait for provider names
|
||||
// before bootstrapping existing session history.
|
||||
|
|
@ -701,13 +833,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
}
|
||||
|
||||
if (!eager && input.resolveSession) {
|
||||
queueMicrotask(() => {
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
void firstPaint
|
||||
.then(() => {
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
void ensureStream().catch(() => {})
|
||||
})
|
||||
return ensureStream()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -731,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<void> {
|
||||
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<void> {
|
||||
const sdk = input.sdk
|
||||
let session: Promise<ResolvedSession> | 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<RunTuiConfig> },
|
||||
deps?: RunRuntimeDeps,
|
||||
): Promise<void> {
|
||||
return runInteractiveRuntime(
|
||||
|
|
@ -797,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,
|
||||
|
|
@ -105,7 +105,7 @@ export class RunScrollbackStream {
|
|||
) {
|
||||
this.diffStyle = options.diffStyle
|
||||
this.sessionID = options.sessionID
|
||||
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
||||
this.treeSitterClient = options.treeSitterClient
|
||||
this.wrote = options.wrote ?? false
|
||||
this.onThemeRelease = options.onThemeRelease
|
||||
}
|
||||
|
|
@ -151,6 +151,7 @@ export class RunScrollbackStream {
|
|||
startOnNewLine: entryFlags(commit).startOnNewLine,
|
||||
})
|
||||
const style = entryLook(commit, this.theme.entry)
|
||||
const treeSitterClient = body.type === "text" ? undefined : (this.treeSitterClient ??= getTreeSitterClient())
|
||||
const renderable =
|
||||
body.type === "text"
|
||||
? new TextRenderable(surface.renderContext, {
|
||||
|
|
@ -170,7 +171,7 @@ export class RunScrollbackStream {
|
|||
drawUnstyledText: false,
|
||||
streaming: true,
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
treeSitterClient,
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
|
|
@ -180,7 +181,7 @@ export class RunScrollbackStream {
|
|||
internalBlockMode: "top-level",
|
||||
tableOptions: { widthMode: "content" },
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
treeSitterClient,
|
||||
})
|
||||
|
||||
surface.root.add(renderable)
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ type SessionCommit = StreamCommit
|
|||
// - sent: part ID → byte offset of last flushed text (for incremental output)
|
||||
// - visible: part ID → rendered text for an active part after display transforms
|
||||
// - end: part IDs whose time.end has arrived (part is finished)
|
||||
// - shell: shell call ID → chosen transcript source for direct shell calls
|
||||
// - shell: shell ID → chosen transcript source for direct shell calls
|
||||
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
||||
type ShellCall = {
|
||||
source: "shell" | "tool"
|
||||
|
|
@ -607,12 +607,12 @@ function toolCommit(
|
|||
}
|
||||
}
|
||||
|
||||
function shellPartID(callID: string): string {
|
||||
return `shell:${callID}`
|
||||
function shellPartID(shellID: string): string {
|
||||
return `shell:${shellID}`
|
||||
}
|
||||
|
||||
function claimShell(data: SessionData, callID: string, source: ShellCall["source"], command?: string): ShellCall {
|
||||
const current = data.shell.get(callID)
|
||||
function claimShell(data: SessionData, shellID: string, source: ShellCall["source"], command?: string): ShellCall {
|
||||
const current = data.shell.get(shellID)
|
||||
if (current) {
|
||||
if (command && !current.command) {
|
||||
current.command = command
|
||||
|
|
@ -625,7 +625,7 @@ function claimShell(data: SessionData, callID: string, source: ShellCall["source
|
|||
source,
|
||||
...(command ? { command } : {}),
|
||||
} satisfies ShellCall
|
||||
data.shell.set(callID, next)
|
||||
data.shell.set(shellID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
|
|
@ -728,37 +728,37 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
|||
const data = input.data
|
||||
const event = input.event
|
||||
|
||||
if (event.type === "session.next.shell.started") {
|
||||
if (event.type === "session.shell.started") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const shell = claimShell(data, event.properties.callID, "shell", event.properties.command)
|
||||
const shell = claimShell(data, event.properties.shell.id, "shell", event.properties.shell.command)
|
||||
if (shell.source !== "shell") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const partID = shellPartID(event.properties.callID)
|
||||
const partID = shellPartID(event.properties.shell.id)
|
||||
if (data.ids.has(partID) || data.tools.has(partID)) {
|
||||
return out(data, commits, patch({ status: "running shell" }))
|
||||
}
|
||||
|
||||
data.tools.add(partID)
|
||||
commits.push(startShell(event.properties.callID, shell.command ?? event.properties.command))
|
||||
commits.push(startShell(event.properties.shell.id, shell.command ?? event.properties.shell.command))
|
||||
return out(data, commits, patch({ status: "running shell" }))
|
||||
}
|
||||
|
||||
if (event.type === "session.next.shell.ended") {
|
||||
if (event.type === "session.shell.ended") {
|
||||
if (event.properties.sessionID !== input.sessionID) {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const shell = claimShell(data, event.properties.callID, "shell")
|
||||
const shell = claimShell(data, event.properties.shell.id, "shell")
|
||||
if (shell.source !== "shell") {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
const partID = shellPartID(event.properties.callID)
|
||||
const partID = shellPartID(event.properties.shell.id)
|
||||
const seen = data.tools.has(partID)
|
||||
const command = shell.command ?? ""
|
||||
data.tools.delete(partID)
|
||||
|
|
@ -767,11 +767,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
|||
}
|
||||
|
||||
if (!seen && command) {
|
||||
commits.push(startShell(event.properties.callID, command))
|
||||
commits.push(startShell(event.properties.shell.id, command))
|
||||
}
|
||||
|
||||
data.ids.add(partID)
|
||||
commits.push(doneShell(event.properties.callID, command, event.properties.output))
|
||||
commits.push(doneShell(event.properties.shell.id, command, event.properties.output.output))
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
|
|
@ -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<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
|
||||
export type SessionMessages = Array<{ info: Message; parts: Part[] }>
|
||||
|
||||
type Turn = {
|
||||
prompt: RunPrompt
|
||||
|
|
@ -20,6 +21,8 @@ type Turn = {
|
|||
export type RunSession = {
|
||||
first: boolean
|
||||
turns: Turn[]
|
||||
model?: NonNullable<RunInput["model"]>
|
||||
variant?: string
|
||||
}
|
||||
|
||||
function fileName(url: string, filename?: string) {
|
||||
|
|
@ -157,9 +160,11 @@ export async function resolveCurrentSession(
|
|||
sessionID: string,
|
||||
limit = LIMIT,
|
||||
): Promise<RunSession> {
|
||||
const response = await sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true })
|
||||
const messages = response.data.data.toReversed()
|
||||
const session = await sdk.v2.session.get({ sessionID }, { throwOnError: true })
|
||||
const [response, session] = await Promise.all([
|
||||
sdk.message.list({ sessionID, limit, order: "desc" }),
|
||||
sdk.session.get({ sessionID }),
|
||||
])
|
||||
const messages = response.data.toReversed()
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
turns: messages.flatMap((message) => {
|
||||
|
|
@ -191,12 +196,19 @@ 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.model && {
|
||||
model: {
|
||||
providerID: session.model.providerID,
|
||||
modelID: session.model.id,
|
||||
},
|
||||
variant: session.model.variant,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +235,10 @@ export function sessionVariant(session: RunSession, model: RunInput["model"]): s
|
|||
return undefined
|
||||
}
|
||||
|
||||
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) {
|
||||
return session.variant
|
||||
}
|
||||
|
||||
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
|
||||
const turn = session.turns[idx]
|
||||
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
||||
|
|
@ -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
|
||||
|
|
@ -15,22 +15,19 @@
|
|||
// 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
|
||||
const CHILD_FRAME_LIMIT = 80
|
||||
const DISCOVERY_BUFFER_LIMIT = 64
|
||||
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")
|
||||
}
|
||||
|
|
@ -160,11 +157,12 @@ type ChildState = {
|
|||
tools: Map<string, ToolTrack>
|
||||
finishedTools: Set<string>
|
||||
messageIDs: Set<string>
|
||||
prompts: Map<string, string>
|
||||
hydrated: boolean
|
||||
}
|
||||
|
||||
export type SubagentTrackerInput = {
|
||||
sdk: OpencodeClient
|
||||
sdk: OpenCodeClient
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
emit: () => void
|
||||
|
|
@ -223,6 +221,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
// Foreign events buffered while a session.get discovery is in flight, so a
|
||||
// fast child (including its settled event) is not lost mid-discovery.
|
||||
const pendingEvents = new Map<string, V2Event[]>()
|
||||
const hydrationEvents = new Map<string, V2Event[]>()
|
||||
const hydrationOverflow = new Set<string>()
|
||||
const hydrations = new Map<string, Promise<void>>()
|
||||
let selected: string | undefined
|
||||
|
||||
|
|
@ -244,6 +244,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
tools: new Map(),
|
||||
finishedTools: new Set(),
|
||||
messageIDs: new Set(),
|
||||
prompts: new Map(),
|
||||
hydrated: false,
|
||||
}
|
||||
if (!existing) children.set(sessionID, child)
|
||||
|
|
@ -332,6 +333,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
child.callIDs.clear()
|
||||
for (const message of messages) {
|
||||
if (message.type === "user") {
|
||||
child.prompts.delete(message.id)
|
||||
userFrame(child, message.id, message.text)
|
||||
continue
|
||||
}
|
||||
|
|
@ -382,16 +384,38 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
const hydrateChild = (child: ChildState): Promise<void> => {
|
||||
const existing = hydrations.get(child.sessionID)
|
||||
if (existing) return existing
|
||||
const task = input.sdk.v2.session
|
||||
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
|
||||
const pendingPrompts = new Map(child.prompts)
|
||||
const pendingTools = new Map(child.tools)
|
||||
let retry = false
|
||||
const task = input.sdk.message
|
||||
.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" })
|
||||
.then((response) => {
|
||||
rebuild(child, response.data.data.toReversed())
|
||||
const buffered = hydrationEvents.get(child.sessionID) ?? []
|
||||
hydrationEvents.delete(child.sessionID)
|
||||
if (hydrationOverflow.delete(child.sessionID)) {
|
||||
child.hydrated = false
|
||||
retry = true
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
for (const [id, prompt] of pendingPrompts) {
|
||||
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
|
||||
}
|
||||
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)
|
||||
}
|
||||
for (const event of buffered) reduce(child, event)
|
||||
child.hydrated = true
|
||||
notifyDetail(child)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {
|
||||
hydrationEvents.delete(child.sessionID)
|
||||
hydrationOverflow.delete(child.sessionID)
|
||||
})
|
||||
.finally(() => {
|
||||
hydrations.delete(child.sessionID)
|
||||
if (retry) queueMicrotask(() => void hydrateChild(child))
|
||||
})
|
||||
hydrations.set(child.sessionID, task)
|
||||
return task
|
||||
|
|
@ -401,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
|
||||
|
|
@ -424,21 +447,27 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
}
|
||||
|
||||
const reduce = (child: ChildState, event: V2Event) => {
|
||||
if (event.type === "session.next.prompted") {
|
||||
if (userFrame(child, event.data.messageID, event.data.prompt.text)) {
|
||||
touch(child, event.data.timestamp)
|
||||
if (event.type === "session.prompt.admitted") {
|
||||
child.prompts.set(event.data.inputID, event.data.prompt.text)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.prompt.promoted") {
|
||||
const prompt = child.prompts.get(event.data.inputID) ?? ""
|
||||
child.prompts.delete(event.data.inputID)
|
||||
if (userFrame(child, event.data.inputID, prompt)) {
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.step.started") {
|
||||
touch(child, event.data.timestamp)
|
||||
if (event.type === "session.step.started") {
|
||||
touch(child, event.created)
|
||||
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
||||
if (child.status !== "running") child.status = "running"
|
||||
input.emit()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.text.delta") {
|
||||
if (event.type === "session.text.delta") {
|
||||
const projected = child.projectedText.get(event.data.textID)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
|
|
@ -455,11 +484,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.textID,
|
||||
})
|
||||
touch(child, event.data.timestamp)
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.text.ended") {
|
||||
if (event.type === "session.text.ended") {
|
||||
child.text.set(event.data.textID, event.data.text)
|
||||
child.projectedText.delete(event.data.textID)
|
||||
setFrame(child, `text:${event.data.textID}`, {
|
||||
|
|
@ -470,11 +499,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
messageID: event.data.assistantMessageID,
|
||||
partID: event.data.textID,
|
||||
})
|
||||
touch(child, event.data.timestamp)
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.reasoning.delta") {
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const projected = child.projectedReasoning.get(event.data.reasoningID)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
if (projected && covered >= 0) {
|
||||
|
|
@ -495,7 +524,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.reasoning.ended") {
|
||||
if (event.type === "session.reasoning.ended") {
|
||||
child.reasoning.set(event.data.reasoningID, event.data.text)
|
||||
child.projectedReasoning.delete(event.data.reasoningID)
|
||||
if (!input.thinking) return
|
||||
|
|
@ -510,40 +539,42 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.tool.input.started") {
|
||||
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp })
|
||||
if (event.type === "session.tool.input.started") {
|
||||
if (child.finishedTools.has(event.data.callID)) return
|
||||
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.tool.called") {
|
||||
if (event.type === "session.tool.called") {
|
||||
if (child.finishedTools.has(event.data.callID)) return
|
||||
const current = child.tools.get(event.data.callID)
|
||||
child.tools.set(event.data.callID, {
|
||||
name: event.data.tool,
|
||||
input: event.data.input,
|
||||
started: current?.started ?? event.data.timestamp,
|
||||
started: current?.started ?? event.created,
|
||||
})
|
||||
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.data.timestamp, ran: event.data.timestamp },
|
||||
},
|
||||
time: { created: current?.started ?? event.created, ran: event.created },
|
||||
}) as SessionMessageAssistantTool,
|
||||
event.data.assistantMessageID,
|
||||
)
|
||||
touch(child, event.data.timestamp)
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") {
|
||||
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
||||
if (child.finishedTools.has(event.data.callID)) return
|
||||
const current = child.tools.get(event.data.callID)
|
||||
const failed = event.type === "session.next.tool.failed"
|
||||
const failed = event.type === "session.tool.failed"
|
||||
childTool(
|
||||
child,
|
||||
{
|
||||
structuredClone({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: current?.name ?? "tool",
|
||||
|
|
@ -566,18 +597,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
result: event.data.result,
|
||||
},
|
||||
time: {
|
||||
created: current?.started ?? event.data.timestamp,
|
||||
created: current?.started ?? event.created,
|
||||
ran: current?.started,
|
||||
completed: event.data.timestamp,
|
||||
completed: event.created,
|
||||
},
|
||||
},
|
||||
}) as SessionMessageAssistantTool,
|
||||
event.data.assistantMessageID,
|
||||
)
|
||||
touch(child, event.data.timestamp)
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.step.failed") {
|
||||
if (event.type === "session.step.failed") {
|
||||
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
|
||||
kind: "error",
|
||||
source: "system",
|
||||
|
|
@ -585,14 +616,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
phase: "start",
|
||||
messageID: event.data.assistantMessageID,
|
||||
})
|
||||
touch(child, event.data.timestamp)
|
||||
touch(child, event.created)
|
||||
notifyDetail(child)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.execution.settled") {
|
||||
if (event.type === "session.execution.settled") {
|
||||
child.status =
|
||||
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
|
||||
touch(child, event.data.timestamp)
|
||||
touch(child, event.created)
|
||||
input.emit()
|
||||
}
|
||||
}
|
||||
|
|
@ -613,15 +644,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
|
||||
return {
|
||||
main(event) {
|
||||
if (event.type === "session.next.tool.called") {
|
||||
if (event.type === "session.tool.called") {
|
||||
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.tool.failed") {
|
||||
if (event.type === "session.tool.failed") {
|
||||
pendingCalls.delete(event.data.callID)
|
||||
return
|
||||
}
|
||||
if (event.type !== "session.next.tool.success") return
|
||||
if (event.type !== "session.tool.success") return
|
||||
const pending = pendingCalls.get(event.data.callID)
|
||||
pendingCalls.delete(event.data.callID)
|
||||
const found = childSessionID(record(event.data.structured))
|
||||
|
|
@ -633,19 +664,25 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
|||
child.status = "running"
|
||||
}
|
||||
if (!found.running && child.status === "running") child.status = "completed"
|
||||
touch(child, event.data.timestamp)
|
||||
touch(child, event.created)
|
||||
input.emit()
|
||||
if (!child.hydrated) void hydrateChild(child)
|
||||
},
|
||||
foreign(sessionID, event) {
|
||||
const child = children.get(sessionID)
|
||||
if (child) {
|
||||
if (hydrations.has(sessionID)) {
|
||||
const buffered = hydrationEvents.get(sessionID) ?? []
|
||||
if (buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event)
|
||||
else hydrationOverflow.add(sessionID)
|
||||
hydrationEvents.set(sessionID, buffered)
|
||||
}
|
||||
reduce(child, event)
|
||||
return
|
||||
}
|
||||
discover(sessionID)
|
||||
const buffered = pendingEvents.get(sessionID)
|
||||
if (buffered && buffered.length < DISCOVERY_BUFFER_LIMIT) buffered.push(event)
|
||||
if (buffered && buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event)
|
||||
},
|
||||
async hydrate(next) {
|
||||
for (const message of next.messages) {
|
||||
|
|
@ -656,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({ limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
|
||||
.then((response) => response.data.data.filter((session) => session.parentID === input.sessionID))
|
||||
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)
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
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"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
|
||||
|
|
@ -30,7 +31,7 @@ type Trace = {
|
|||
}
|
||||
|
||||
type StreamInput = {
|
||||
sdk: OpencodeClient
|
||||
sdk: OpenCodeClient
|
||||
directory?: string
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
|
|
@ -41,6 +42,7 @@ type StreamInput = {
|
|||
footer: FooterApi
|
||||
trace?: Trace
|
||||
signal?: AbortSignal
|
||||
onCatalogRefresh?: () => void
|
||||
}
|
||||
|
||||
export type SessionTurnInput = {
|
||||
|
|
@ -77,7 +79,20 @@ type Wait = {
|
|||
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
|
||||
}
|
||||
|
||||
type RunV2Event = V2Event
|
||||
// One active session.shell call. The HTTP response is the completion signal;
|
||||
// callID correlates the live shell events once shell.started is observed, and
|
||||
// abort cancels the blocking request when the user interrupts the turn.
|
||||
type ShellWait = {
|
||||
eventID: string
|
||||
messageID: string
|
||||
callID?: string
|
||||
resolve: () => void
|
||||
abort: () => void
|
||||
}
|
||||
|
||||
type RunV2Event = EventSubscribeOutput
|
||||
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
|
||||
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
|
||||
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
|
||||
|
||||
type ToolState = {
|
||||
|
|
@ -99,6 +114,11 @@ type State = {
|
|||
projectedReasoning: Map<string, string>
|
||||
tools: Map<string, ToolState>
|
||||
finishedTools: Set<string>
|
||||
skillMessages: Set<string>
|
||||
shellCommands: Map<string, string>
|
||||
shellStarted: Set<string>
|
||||
shellEnded: Set<string>
|
||||
shellWait?: ShellWait
|
||||
wait?: Wait
|
||||
connected: boolean
|
||||
closed: boolean
|
||||
|
|
@ -126,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,
|
||||
}
|
||||
}
|
||||
|
|
@ -137,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,
|
||||
}
|
||||
}
|
||||
|
|
@ -179,22 +199,120 @@ function promptFileSource(part: PromptFilePart) {
|
|||
}
|
||||
}
|
||||
|
||||
function promptFiles(next: SessionTurnInput) {
|
||||
return next.prompt.parts.flatMap((part) =>
|
||||
part.type === "file"
|
||||
? [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
source: promptFileSource(part),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function promptAgents(next: SessionTurnInput) {
|
||||
return next.prompt.parts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
source: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function streamPartKey(messageID: string, partID: string) {
|
||||
return `${messageID}\u0000${partID}`
|
||||
}
|
||||
|
||||
// Matches the commit shapes the legacy session-data reducer produced for direct
|
||||
// shell calls: one "start" commit rendering `$ command` and one "progress"
|
||||
// commit rendering the merged output (see toolEntryBody in tool.ts).
|
||||
function shellCommit(
|
||||
callID: string,
|
||||
command: string,
|
||||
next: Pick<StreamCommit, "text" | "phase" | "toolState" | "toolError">,
|
||||
): StreamCommit {
|
||||
return {
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
partID: `shell:${callID}`,
|
||||
tool: "bash",
|
||||
shell: { callID, command },
|
||||
...next,
|
||||
}
|
||||
}
|
||||
|
||||
function shellTerminal(
|
||||
callID: string,
|
||||
command: string,
|
||||
shell: { status: string; exit?: number | string },
|
||||
output: { output: string; cursor: number; size: number; truncated: boolean },
|
||||
) {
|
||||
const incomplete = output.truncated || output.cursor < output.size
|
||||
const text = `${output.output}${incomplete ? `${output.output.endsWith("\n") || !output.output ? "" : "\n"}[output truncated]` : ""}`
|
||||
const error =
|
||||
shell.status === "exited" && shell.exit === 0
|
||||
? undefined
|
||||
: shell.status === "exited"
|
||||
? `Shell exited with code ${shell.exit ?? "unknown"}`
|
||||
: `Shell ${shell.status}`
|
||||
if (!error)
|
||||
return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
|
||||
return [
|
||||
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
|
||||
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
|
||||
]
|
||||
}
|
||||
|
||||
function messageIDFromEvent(id: string) {
|
||||
return id.replace(/^evt_/, "msg_")
|
||||
}
|
||||
|
||||
const catalogEvents = new Set([
|
||||
"catalog.updated",
|
||||
"integration.updated",
|
||||
"agent.updated",
|
||||
"command.updated",
|
||||
"skill.updated",
|
||||
"reference.updated",
|
||||
])
|
||||
|
||||
// session.shell resolves after the command settled server-side; the matching
|
||||
// live shell.ended event usually lands within the same tick, but hold the turn
|
||||
// briefly so the output commit renders inside it.
|
||||
const SHELL_OUTPUT_GRACE_MS = 1500
|
||||
|
||||
function skillCommit(messageID: string, name: string): StreamCommit {
|
||||
return {
|
||||
kind: "system",
|
||||
source: "system",
|
||||
messageID,
|
||||
partID: `skill:${messageID}`,
|
||||
text: `→ Skill "${name}"`,
|
||||
phase: "start",
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnInput, "model" | "variant" | "signal">) {
|
||||
if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
||||
if (!next.variant) return
|
||||
|
||||
const session = await input.sdk.v2.session
|
||||
.get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal })
|
||||
.then((response) => 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 }
|
||||
}
|
||||
|
|
@ -213,6 +331,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
projectedReasoning: new Map(),
|
||||
tools: new Map(),
|
||||
finishedTools: new Set(),
|
||||
skillMessages: new Set(),
|
||||
shellCommands: new Map(),
|
||||
shellStarted: new Set(),
|
||||
shellEnded: new Set(),
|
||||
connected: false,
|
||||
closed: false,
|
||||
initial: true,
|
||||
|
|
@ -294,7 +416,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
if (!state.tools.get(item.id)?.running) write([toolCommit(part, "start")])
|
||||
state.finishedTools.add(item.id)
|
||||
state.tools.delete(item.id)
|
||||
write([toolCommit(part, item.state.status === "completed" && part.state.status === "completed" && part.state.output ? "progress" : "final")])
|
||||
write([
|
||||
toolCommit(
|
||||
part,
|
||||
item.state.status === "completed" && part.state.status === "completed" && part.state.output
|
||||
? "progress"
|
||||
: "final",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => {
|
||||
|
|
@ -307,6 +436,47 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
|
||||
return
|
||||
}
|
||||
if (message.type === "skill") {
|
||||
if (state.wait?.messageID === message.id) state.wait.promoted = true
|
||||
if (!render || state.skillMessages.has(message.id)) {
|
||||
state.skillMessages.add(message.id)
|
||||
return
|
||||
}
|
||||
state.skillMessages.add(message.id)
|
||||
write([skillCommit(message.id, message.name)])
|
||||
return
|
||||
}
|
||||
if (message.type === "shell") {
|
||||
state.shellCommands.set(message.shell.id, message.shell.command)
|
||||
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shell.id
|
||||
const completed = message.time.completed !== undefined
|
||||
if (!render) {
|
||||
// Suppressed history: mark settled shells rendered so live redelivery
|
||||
// stays silent. A still-running shell stays unmarked and renders in
|
||||
// full when its live shell.ended event arrives.
|
||||
if (completed) {
|
||||
state.shellStarted.add(message.shell.id)
|
||||
state.shellEnded.add(message.shell.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!state.shellStarted.has(message.shell.id)) {
|
||||
state.shellStarted.add(message.shell.id)
|
||||
write([
|
||||
shellCommit(message.shell.id, message.shell.command, {
|
||||
text: "running shell",
|
||||
phase: "start",
|
||||
toolState: "running",
|
||||
}),
|
||||
])
|
||||
}
|
||||
if (completed && message.output && !state.shellEnded.has(message.shell.id)) {
|
||||
state.shellEnded.add(message.shell.id)
|
||||
write(shellTerminal(message.shell.id, message.shell.command, message.shell, message.output))
|
||||
}
|
||||
if (completed && state.shellWait?.callID === message.shell.id) state.shellWait.resolve()
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
state.messageIDs.add(message.id)
|
||||
for (const item of message.content) {
|
||||
|
|
@ -364,21 +534,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
|
||||
const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => {
|
||||
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
|
||||
|
|
@ -388,6 +555,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
|
||||
const apply = (event: RunV2Event) => {
|
||||
if (catalogEvents.has(event.type)) {
|
||||
if (input.directory && event.location?.directory && event.location.directory !== input.directory) return
|
||||
input.onCatalogRefresh?.()
|
||||
return
|
||||
}
|
||||
const source = sessionID(event)
|
||||
if (source !== input.sessionID) {
|
||||
if (source) subagents.foreign(source, event)
|
||||
|
|
@ -395,17 +567,66 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
}
|
||||
input.trace?.write("recv.event", event)
|
||||
subagents.main(event)
|
||||
if (event.type === "session.next.prompted") {
|
||||
if (state.wait?.messageID === event.data.messageID) state.wait.promoted = true
|
||||
state.messageIDs.add(event.data.messageID)
|
||||
if (event.type === "session.prompt.promoted") {
|
||||
if (state.wait?.messageID === event.data.inputID) state.wait.promoted = true
|
||||
state.messageIDs.add(event.data.inputID)
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.step.started") {
|
||||
if (event.type === "session.step.started") {
|
||||
write([], { phase: "running", status: "assistant responding" })
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.text.delta") {
|
||||
if (event.type === "session.skill.activated") {
|
||||
const messageID = messageIDFromEvent(event.id)
|
||||
if (state.wait?.messageID === messageID) state.wait.promoted = true
|
||||
if (state.skillMessages.has(messageID)) return
|
||||
state.skillMessages.add(messageID)
|
||||
write([skillCommit(messageID, event.data.name)])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.shell.started") {
|
||||
state.shellCommands.set(event.data.shell.id, event.data.shell.command)
|
||||
const wait = state.shellWait
|
||||
if (wait?.eventID === event.id) wait.callID = event.data.shell.id
|
||||
if (state.shellStarted.has(event.data.shell.id)) return
|
||||
state.shellStarted.add(event.data.shell.id)
|
||||
write(
|
||||
[
|
||||
shellCommit(event.data.shell.id, event.data.shell.command, {
|
||||
text: "running shell",
|
||||
phase: "start",
|
||||
toolState: "running",
|
||||
}),
|
||||
],
|
||||
{
|
||||
phase: "running",
|
||||
status: "running shell",
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.shell.ended") {
|
||||
const command = state.shellCommands.get(event.data.shell.id) ?? event.data.shell.command
|
||||
const commits: StreamCommit[] = []
|
||||
if (!state.shellStarted.has(event.data.shell.id)) {
|
||||
state.shellStarted.add(event.data.shell.id)
|
||||
if (command)
|
||||
commits.push(
|
||||
shellCommit(event.data.shell.id, command, { text: "running shell", phase: "start", toolState: "running" }),
|
||||
)
|
||||
}
|
||||
if (!state.shellEnded.has(event.data.shell.id)) {
|
||||
state.shellEnded.add(event.data.shell.id)
|
||||
commits.push(...shellTerminal(event.data.shell.id, command, event.data.shell, event.data.output))
|
||||
}
|
||||
const wait = state.shellWait
|
||||
const owned = wait?.callID === event.data.shell.id
|
||||
write(commits, owned || state.wait || state.shellWait ? undefined : { phase: "idle", status: "" })
|
||||
if (owned) wait.resolve()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
|
||||
const projected = state.projectedText.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
|
|
@ -427,7 +648,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.text.ended") {
|
||||
if (event.type === "session.text.ended") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
|
||||
const previous = state.text.get(key) ?? ""
|
||||
if (event.data.text.length > previous.length)
|
||||
|
|
@ -445,7 +666,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
state.projectedText.delete(key)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.reasoning.delta") {
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
|
||||
const projected = state.projectedReasoning.get(key)
|
||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
||||
|
|
@ -468,7 +689,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.reasoning.ended") {
|
||||
if (event.type === "session.reasoning.ended") {
|
||||
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
|
||||
const previous = state.reasoning.get(key) ?? ""
|
||||
if (input.thinking && event.data.text.length > previous.length)
|
||||
|
|
@ -486,41 +707,48 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
state.projectedReasoning.delete(key)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.tool.input.started") {
|
||||
if (event.type === "session.tool.input.started") {
|
||||
state.tools.set(event.data.callID, {
|
||||
messageID: event.data.assistantMessageID,
|
||||
name: event.data.name,
|
||||
input: {},
|
||||
started: event.data.timestamp,
|
||||
started: event.created,
|
||||
running: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.tool.called") {
|
||||
if (event.type === "session.tool.called") {
|
||||
if (state.finishedTools.has(event.data.callID)) return
|
||||
const current = state.tools.get(event.data.callID)
|
||||
const item: SessionMessageAssistantTool = {
|
||||
const item = 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.data.timestamp, ran: event.data.timestamp },
|
||||
}
|
||||
time: { created: current?.started ?? event.created, ran: event.created },
|
||||
}) as SessionMessageAssistantTool
|
||||
renderTool(event.data.assistantMessageID, item)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.tool.progress") return
|
||||
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") {
|
||||
if (event.type === "session.tool.progress") return
|
||||
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
||||
const current = state.tools.get(event.data.callID)
|
||||
const failed = event.type === "session.next.tool.failed"
|
||||
const item: SessionMessageAssistantTool = {
|
||||
const failed = event.type === "session.tool.failed"
|
||||
const item = structuredClone({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: current?.name ?? "tool",
|
||||
provider: event.data.provider,
|
||||
state: failed
|
||||
? { status: "error", input: current?.input ?? {}, structured: {}, content: [], error: event.data.error, result: event.data.result }
|
||||
? {
|
||||
status: "error",
|
||||
input: current?.input ?? {},
|
||||
structured: {},
|
||||
content: [],
|
||||
error: event.data.error,
|
||||
result: event.data.result,
|
||||
}
|
||||
: {
|
||||
status: "completed",
|
||||
input: current?.input ?? {},
|
||||
|
|
@ -529,8 +757,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
outputPaths: event.data.outputPaths,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { created: current?.started ?? event.data.timestamp, ran: current?.started, completed: event.data.timestamp },
|
||||
}
|
||||
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
|
||||
}) as SessionMessageAssistantTool
|
||||
renderTool(event.data.assistantMessageID, item)
|
||||
return
|
||||
}
|
||||
|
|
@ -554,7 +782,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
syncBlockers()
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.step.ended") {
|
||||
if (event.type === "session.step.ended") {
|
||||
const total =
|
||||
event.data.tokens.input +
|
||||
event.data.tokens.output +
|
||||
|
|
@ -562,16 +790,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
event.data.tokens.cache.read +
|
||||
event.data.tokens.cache.write
|
||||
const usage = total > 0 ? total.toLocaleString() : ""
|
||||
write([], { phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage })
|
||||
write([], {
|
||||
phase: event.data.finish === "tool-calls" ? "running" : "idle",
|
||||
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.step.failed") {
|
||||
if (event.type === "session.step.failed") {
|
||||
state.errors.add(event.data.assistantMessageID)
|
||||
if (state.wait) state.wait.failureRendered = true
|
||||
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.next.execution.settled") {
|
||||
if (event.type === "session.execution.settled") {
|
||||
write([], { phase: "idle", status: "" })
|
||||
const current = state.wait
|
||||
if (!current || (!current.promoted && !current.interrupted)) return
|
||||
|
|
@ -606,12 +837,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
const connection = new AbortController()
|
||||
const abortConnection = () => 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<RunV2Event>
|
||||
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")
|
||||
|
|
@ -627,6 +853,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
})()
|
||||
void consume.catch(() => {})
|
||||
await hydrate({ render: state.initial ? input.replay === true : true, reuseVisibleWait: !state.initial })
|
||||
input.onCatalogRefresh?.()
|
||||
state.initial = false
|
||||
booting = false
|
||||
for (const event of buffered.splice(0)) apply(event)
|
||||
|
|
@ -656,108 +883,185 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
controller.signal.removeEventListener("abort", abortReady)
|
||||
}
|
||||
|
||||
const runShellTurn = async (next: SessionTurnInput) => {
|
||||
if (state.wait || state.shellWait) throw new Error("prompt already running")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const abort = new AbortController()
|
||||
const onAbort = () => abort.abort()
|
||||
next.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
let rendered!: () => void
|
||||
const output = new Promise<void>((resolve) => {
|
||||
rendered = resolve
|
||||
})
|
||||
const eventID = Event.ID.create()
|
||||
const active: ShellWait = {
|
||||
eventID,
|
||||
messageID: messageIDFromEvent(eventID),
|
||||
resolve: rendered,
|
||||
abort: () => abort.abort(),
|
||||
}
|
||||
state.shellWait = active
|
||||
input.trace?.write("send.shell", { sessionID: input.sessionID, id: eventID, command: next.prompt.text })
|
||||
write([], { phase: "running", status: "running shell" })
|
||||
try {
|
||||
await input.sdk.session.shell(
|
||||
{ sessionID: input.sessionID, id: eventID, command: next.prompt.text },
|
||||
{ signal: abort.signal },
|
||||
)
|
||||
await Promise.race([output, wait(SHELL_OUTPUT_GRACE_MS, abort.signal)])
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) return
|
||||
throw error
|
||||
} finally {
|
||||
next.signal?.removeEventListener("abort", onAbort)
|
||||
if (state.shellWait === active) state.shellWait = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Shared settlement scaffolding for prompt-shaped turns: registers the wait,
|
||||
// wires interruption, sends, then blocks until the live settled event (or a
|
||||
// hydration pass over an idle session) resolves it.
|
||||
const runTurnWait = async (
|
||||
next: SessionTurnInput,
|
||||
messageID: string,
|
||||
turn: { promoted?: boolean; send: () => Promise<unknown> },
|
||||
) => {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: unknown) => void
|
||||
const done = new Promise<void>((ok, fail) => {
|
||||
resolve = ok
|
||||
reject = fail
|
||||
})
|
||||
const active: Wait = {
|
||||
messageID,
|
||||
promoted: turn.promoted === true,
|
||||
interrupted: false,
|
||||
failureRendered: false,
|
||||
resolve,
|
||||
reject,
|
||||
onVisibleOutput: next.onVisibleOutput,
|
||||
}
|
||||
state.wait = active
|
||||
const interrupt = () => {
|
||||
active.interrupted = true
|
||||
void input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
next.signal?.addEventListener("abort", interrupt, { once: true })
|
||||
try {
|
||||
await turn.send()
|
||||
await done
|
||||
} catch (error) {
|
||||
if (state.wait === active) state.wait = undefined
|
||||
if (next.signal?.aborted) return
|
||||
throw error
|
||||
} finally {
|
||||
next.signal?.removeEventListener("abort", interrupt)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async runPromptTurn(next) {
|
||||
if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts")
|
||||
if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts")
|
||||
if (state.wait) throw new Error("prompt already running")
|
||||
if (next.prompt.mode === "shell") {
|
||||
await runShellTurn(next)
|
||||
return
|
||||
}
|
||||
if (state.wait || state.shellWait) throw new Error("prompt already running")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const messageID = next.prompt.messageID
|
||||
if (!messageID) throw new Error("Prompt message ID is required")
|
||||
|
||||
const command = next.prompt.command
|
||||
if (command?.source === "skill") {
|
||||
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
input.sdk.session.skill(
|
||||
{ sessionID: input.sessionID, id: messageID, skill: command.name },
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
const selected = await resolveSelectedModel(input, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
// Agent and model ride the command payload; the server switches only
|
||||
// when the command itself does not pin them.
|
||||
const files = [
|
||||
...(next.includeFiles ? next.files : []).map((file) => ({ uri: file.url, name: file.filename })),
|
||||
...promptFiles(next),
|
||||
]
|
||||
const agents = promptAgents(next)
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
input.sdk.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
command: command.name,
|
||||
arguments: command.arguments,
|
||||
agent: next.agent,
|
||||
model: selected,
|
||||
files: files.length ? files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (next.agent) {
|
||||
await input.sdk.v2.session.switchAgent(
|
||||
await input.sdk.session.switchAgent(
|
||||
{ sessionID: input.sessionID, agent: next.agent },
|
||||
{ throwOnError: true, signal: next.signal },
|
||||
{ signal: next.signal },
|
||||
)
|
||||
}
|
||||
const selected = await resolveSelectedModel(input, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
if (selected)
|
||||
await input.sdk.v2.session.switchModel(
|
||||
await input.sdk.session.switchModel(
|
||||
{ sessionID: input.sessionID, model: selected },
|
||||
{ throwOnError: true, signal: next.signal },
|
||||
{ signal: next.signal },
|
||||
)
|
||||
|
||||
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
|
||||
const promptFiles = next.prompt.parts.flatMap((part) =>
|
||||
part.type === "file"
|
||||
? [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
source: promptFileSource(part),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
const attachments = [
|
||||
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
|
||||
...promptFiles,
|
||||
...promptFiles(next),
|
||||
]
|
||||
const agents = next.prompt.parts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
source: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
const agents = promptAgents(next)
|
||||
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
|
||||
await runTurnWait(next, messageID, {
|
||||
send: () =>
|
||||
input.sdk.session.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
prompt: {
|
||||
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
||||
files: attachments.length ? attachments : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
const messageID = next.prompt.messageID
|
||||
if (!messageID) throw new Error("Prompt message ID is required")
|
||||
let resolve!: () => void
|
||||
let reject!: (error: unknown) => void
|
||||
const done = new Promise<void>((done, fail) => {
|
||||
resolve = done
|
||||
reject = fail
|
||||
})
|
||||
const active: Wait = {
|
||||
messageID,
|
||||
promoted: false,
|
||||
interrupted: false,
|
||||
failureRendered: false,
|
||||
resolve,
|
||||
reject,
|
||||
onVisibleOutput: next.onVisibleOutput,
|
||||
}
|
||||
state.wait = active
|
||||
const interrupt = () => {
|
||||
active.interrupted = true
|
||||
void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
next.signal?.addEventListener("abort", interrupt, { once: true })
|
||||
try {
|
||||
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
|
||||
await input.sdk.v2.session.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
prompt: {
|
||||
text: [
|
||||
next.prompt.text,
|
||||
...prepared.flatMap((file) => (file.text ? [file.text] : [])),
|
||||
].join("\n\n"),
|
||||
files: attachments.length ? attachments : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
delivery: "steer",
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
{ throwOnError: true, signal: next.signal },
|
||||
)
|
||||
await done
|
||||
} catch (error) {
|
||||
if (state.wait === active) state.wait = undefined
|
||||
if (next.signal?.aborted) return
|
||||
throw error
|
||||
} finally {
|
||||
next.signal?.removeEventListener("abort", interrupt)
|
||||
}
|
||||
{ signal: next.signal },
|
||||
),
|
||||
})
|
||||
},
|
||||
async interruptActiveTurn() {
|
||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||
// abort the blocking request instead. The server-side command keeps its
|
||||
// own lifecycle and simply loses its waiter.
|
||||
const shell = state.shellWait
|
||||
if (shell) {
|
||||
shell.abort()
|
||||
return
|
||||
}
|
||||
if (state.wait) state.wait.interrupted = true
|
||||
await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
await input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
},
|
||||
selectSubagent(sessionID) {
|
||||
subagents.select(sessionID)
|
||||
|
|
@ -776,6 +1080,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
state.projectedReasoning.clear()
|
||||
state.tools.clear()
|
||||
state.finishedTools.clear()
|
||||
state.skillMessages.clear()
|
||||
state.shellCommands.clear()
|
||||
state.shellStarted.clear()
|
||||
state.shellEnded.clear()
|
||||
state.errors.clear()
|
||||
await hydrate({ render: true, reuseVisibleWait: false })
|
||||
} finally {
|
||||
|
|
@ -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<string, unknown>
|
||||
|
||||
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<T = Tool.Info> = {
|
||||
input: Partial<Tool.InferParameters<T>>
|
||||
metadata: Partial<Tool.InferMetadata<T>>
|
||||
export type ToolProps = {
|
||||
input: ToolInput
|
||||
metadata: ToolMetadata
|
||||
frame: ToolFrame
|
||||
}
|
||||
|
||||
type ToolPermissionProps<T = Tool.Info> = {
|
||||
input: Partial<Tool.InferParameters<T>>
|
||||
metadata: Partial<Tool.InferMetadata<T>>
|
||||
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<T = Tool.Info> = {
|
||||
type ToolRule = {
|
||||
view: ToolView
|
||||
run: (props: ToolProps<T>) => ToolInline
|
||||
scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
|
||||
permission?: (props: ToolPermissionProps<T>) => ToolPermissionInfo
|
||||
snap?: (props: ToolProps<T>) => ToolSnapshot | undefined
|
||||
run: (props: ToolProps) => ToolInline
|
||||
scroll?: Partial<Record<ToolPhase, (props: ToolProps) => string>>
|
||||
permission?: (props: ToolPermissionProps) => ToolPermissionInfo
|
||||
snap?: (props: ToolProps) => ToolSnapshot | undefined
|
||||
}
|
||||
|
||||
type ToolRegistry = {
|
||||
[K in ToolName]: ToolRule<ToolDefs[K]>
|
||||
}
|
||||
type ToolRegistry = Record<ToolName, ToolRule>
|
||||
|
||||
type AnyToolRule = ToolRule
|
||||
|
||||
|
|
@ -136,22 +154,28 @@ function dict(v: unknown): ToolDict {
|
|||
return { ...v }
|
||||
}
|
||||
|
||||
function props<T = Tool.Info>(frame: ToolFrame): ToolProps<T> {
|
||||
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<T = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
|
||||
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<typeof GlobTool>): 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<typeof GlobTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runGrep(p: ToolProps<typeof GrepTool>): 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<typeof ReadTool>): 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<typeof ReadTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
|
||||
function runWrite(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Write ${toolPath(p.input.filePath)}`,
|
||||
|
|
@ -338,7 +362,7 @@ function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
|
||||
function runWebfetch(p: ToolProps): ToolInline {
|
||||
const url = p.input.url ?? ""
|
||||
return {
|
||||
icon: "%",
|
||||
|
|
@ -346,7 +370,7 @@ function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
|
||||
function runEdit(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "←",
|
||||
title: `Edit ${toolPath(p.input.filePath)}`,
|
||||
|
|
@ -355,7 +379,7 @@ function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
|
||||
function runWebSearch(p: ToolProps): ToolInline {
|
||||
const title = webSearchProviderLabel(p.metadata.provider)
|
||||
return {
|
||||
icon: "◈",
|
||||
|
|
@ -363,7 +387,7 @@ function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runTask(p: ToolProps<typeof TaskTool>): 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<typeof TaskTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
|
||||
function runTodo(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "#",
|
||||
title: "Todos",
|
||||
|
|
@ -393,14 +417,14 @@ function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runSkill(p: ToolProps<typeof SkillTool>): ToolInline {
|
||||
function runSkill(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Skill "${p.input.name ?? ""}"`,
|
||||
}
|
||||
}
|
||||
|
||||
function runPatch(p: ToolProps<typeof ApplyPatchTool>): 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<typeof ApplyPatchTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
|
||||
function runQuestion(p: ToolProps): ToolInline {
|
||||
const total = list(p.frame.input.questions).length
|
||||
return {
|
||||
icon: "→",
|
||||
|
|
@ -423,7 +447,7 @@ function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
function runInvalid(p: ToolProps<typeof InvalidTool>): 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<typeof LspTool>): ToolInline {
|
||||
function runLsp(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "→",
|
||||
title: text(p.frame.state.title) || lspTitle(p.input),
|
||||
}
|
||||
}
|
||||
|
||||
function runPlanExit(p: ToolProps<typeof PlanExitTool>): 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<typeof PlanExitTool>): ToolInline {
|
|||
}
|
||||
}
|
||||
|
||||
type PatchFile = Tool.InferMetadata<typeof ApplyPatchTool>["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<typeof WriteTool>): 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<typeof WriteTool>): ToolSnapshot | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function snapEdit(p: ToolProps<typeof EditTool>): 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<typeof EditTool>): ToolSnapshot | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefined {
|
||||
function snapPatch(p: ToolProps): ToolSnapshot | undefined {
|
||||
const files = list<PatchFile>(p.frame.meta.files)
|
||||
if (files.length === 0) {
|
||||
return undefined
|
||||
|
|
@ -568,7 +590,7 @@ function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefine
|
|||
}
|
||||
}
|
||||
|
||||
function snapTask(p: ToolProps<typeof TaskTool>): 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<typeof TaskTool>): ToolSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
function snapTodo(p: ToolProps<typeof TodoWriteTool>): 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<typeof TodoWriteTool>): ToolSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
|
||||
function snapQuestion(p: ToolProps): ToolSnapshot {
|
||||
const answers = list<unknown[]>(p.frame.meta.answers)
|
||||
const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
|
||||
const answer = list<string>(answers[i]).filter((entry) => typeof entry === "string")
|
||||
|
|
@ -621,7 +643,7 @@ function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
function scrollBashStart(p: ToolProps<typeof BashTool>): 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<typeof BashTool>): string {
|
|||
return `# Running in ${dir}\n$ ${cmd}`
|
||||
}
|
||||
|
||||
function scrollBashProgress(p: ToolProps<typeof BashTool>): 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,11 @@ function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
|
|||
return fmt(out)
|
||||
}
|
||||
|
||||
function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
|
||||
function scrollBashFinal(p: ToolProps): string {
|
||||
if (p.frame.status === "error") {
|
||||
return fail(p.frame)
|
||||
}
|
||||
|
||||
const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
|
||||
const time = span(p.frame.state)
|
||||
if (code === undefined) {
|
||||
|
|
@ -684,22 +710,22 @@ function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
|
|||
return `bash completed (exit ${code})${time ? ` · ${time}` : ""}`
|
||||
}
|
||||
|
||||
function scrollReadStart(p: ToolProps<typeof ReadTool>): 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<typeof WriteTool>): string {
|
||||
function scrollWriteStart(_: ToolProps): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
function scrollEditStart(_: ToolProps<typeof EditTool>): string {
|
||||
function scrollEditStart(_: ToolProps): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
function scrollPatchStart(_: ToolProps<typeof ApplyPatchTool>): string {
|
||||
function scrollPatchStart(_: ToolProps): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -723,7 +749,7 @@ function patchLine(file: PatchFile): string {
|
|||
return `~ Patched ${rel || toolPath(from)}`
|
||||
}
|
||||
|
||||
function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
|
||||
function scrollPatchFinal(p: ToolProps): string {
|
||||
if (p.frame.status === "error") {
|
||||
return fail(p.frame)
|
||||
}
|
||||
|
|
@ -752,7 +778,7 @@ function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
|
|||
return patchLine(files[0]!)
|
||||
}
|
||||
|
||||
function scrollTaskStart(_: ToolProps<typeof TaskTool>): string {
|
||||
function scrollTaskStart(_: ToolProps): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -774,7 +800,7 @@ function taskResult(output: string): string | undefined {
|
|||
return next || undefined
|
||||
}
|
||||
|
||||
function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
|
||||
function scrollTaskFinal(p: ToolProps): string {
|
||||
if (p.frame.status === "error") {
|
||||
return fail(p.frame)
|
||||
}
|
||||
|
|
@ -788,11 +814,11 @@ function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
|
|||
return `# ${kind} Task\n${row}`
|
||||
}
|
||||
|
||||
function scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string {
|
||||
function scrollTodoStart(_: ToolProps): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
|
||||
function scrollTodoFinal(p: ToolProps): string {
|
||||
const items = list<{ status?: string }>(p.input.todos)
|
||||
const time = span(p.frame.state)
|
||||
if (items.length === 0) {
|
||||
|
|
@ -824,11 +850,11 @@ function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
|
|||
return tail.join(" · ")
|
||||
}
|
||||
|
||||
function scrollQuestionStart(_: ToolProps<typeof QuestionTool>): string {
|
||||
function scrollQuestionStart(_: ToolProps): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
|
||||
function scrollQuestionFinal(p: ToolProps): string {
|
||||
const q = p.input.questions ?? []
|
||||
const a = p.metadata.answers ?? []
|
||||
const time = span(p.frame.state)
|
||||
|
|
@ -855,15 +881,15 @@ function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
|
|||
return rows.join("\n")
|
||||
}
|
||||
|
||||
function scrollLspStart(p: ToolProps<typeof LspTool>): string {
|
||||
function scrollLspStart(p: ToolProps): string {
|
||||
return `→ ${lspTitle(p.input)}`
|
||||
}
|
||||
|
||||
function scrollSkillStart(p: ToolProps<typeof SkillTool>): string {
|
||||
function scrollSkillStart(p: ToolProps): string {
|
||||
return `→ Skill "${p.input.name ?? ""}"`
|
||||
}
|
||||
|
||||
function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
|
||||
function scrollGlobStart(p: ToolProps): string {
|
||||
const pattern = p.input.pattern ?? ""
|
||||
const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob"
|
||||
const dir = p.input.path ?? ""
|
||||
|
|
@ -874,11 +900,11 @@ function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
|
|||
return `${head} in ${toolPath(dir)}`
|
||||
}
|
||||
|
||||
function scrollGlobFinal(p: ToolProps<typeof GlobTool>): string {
|
||||
function scrollGlobFinal(p: ToolProps): string {
|
||||
return toolError(p.frame) || fail(p.frame)
|
||||
}
|
||||
|
||||
function scrollGrepStart(p: ToolProps<typeof GrepTool>): string {
|
||||
function scrollGrepStart(p: ToolProps): string {
|
||||
const pattern = p.input.pattern ?? ""
|
||||
const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep"
|
||||
const dir = p.input.path ?? ""
|
||||
|
|
@ -898,7 +924,7 @@ function scrollListStart(p: ToolProps): string {
|
|||
return `→ List ${toolPath(dir)}`
|
||||
}
|
||||
|
||||
function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
|
||||
function scrollWebfetchStart(p: ToolProps): string {
|
||||
const url = p.input.url ?? ""
|
||||
if (!url) {
|
||||
return "% WebFetch"
|
||||
|
|
@ -907,7 +933,7 @@ function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
|
|||
return `% WebFetch ${url}`
|
||||
}
|
||||
|
||||
function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
|
||||
function scrollWebSearchStart(p: ToolProps): string {
|
||||
const title = webSearchProviderLabel(p.metadata.provider)
|
||||
const query = p.input.query ?? ""
|
||||
if (!query) {
|
||||
|
|
@ -917,7 +943,7 @@ function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
|
|||
return `◈ ${title} "${query}"`
|
||||
}
|
||||
|
||||
function permEdit(p: ToolPermissionProps<typeof EditTool>): 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 {
|
||||
|
|
@ -929,7 +955,7 @@ function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
|
|||
}
|
||||
}
|
||||
|
||||
function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
|
||||
function permRead(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const file = p.input.filePath || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "→",
|
||||
|
|
@ -938,7 +964,7 @@ function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
|
|||
}
|
||||
}
|
||||
|
||||
function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
|
||||
function permGlob(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const pattern = p.input.pattern || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "✱",
|
||||
|
|
@ -947,7 +973,7 @@ function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
|
|||
}
|
||||
}
|
||||
|
||||
function permGrep(p: ToolPermissionProps<typeof GrepTool>): ToolPermissionInfo {
|
||||
function permGrep(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const pattern = p.input.pattern || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "✱",
|
||||
|
|
@ -965,7 +991,7 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
|
|||
}
|
||||
}
|
||||
|
||||
function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
|
||||
function permBash(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const cmd = p.input.command || ""
|
||||
return {
|
||||
icon: "#",
|
||||
|
|
@ -974,7 +1000,7 @@ function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
|
|||
}
|
||||
}
|
||||
|
||||
function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
|
||||
function permTask(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const type = p.input.subagent_type || "general"
|
||||
const desc = p.input.description
|
||||
return {
|
||||
|
|
@ -984,7 +1010,7 @@ function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
|
|||
}
|
||||
}
|
||||
|
||||
function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissionInfo {
|
||||
function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const url = p.input.url || ""
|
||||
return {
|
||||
icon: "%",
|
||||
|
|
@ -993,7 +1019,7 @@ function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissi
|
|||
}
|
||||
}
|
||||
|
||||
function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermissionInfo {
|
||||
function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const query = p.input.query || ""
|
||||
const title = webSearchProviderLabel(p.metadata.provider)
|
||||
return {
|
||||
|
|
@ -1003,7 +1029,7 @@ function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermis
|
|||
}
|
||||
}
|
||||
|
||||
function permLsp(p: ToolPermissionProps<typeof LspTool>): 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
|
||||
|
|
@ -1269,7 +1295,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
|
|||
}
|
||||
}
|
||||
|
||||
function runBash(p: ToolProps<typeof BashTool>): ToolInline {
|
||||
function runBash(p: ToolProps): ToolInline {
|
||||
return {
|
||||
icon: "$",
|
||||
title: p.input.command || "",
|
||||
|
|
@ -1427,6 +1453,11 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody |
|
|||
return textBody(shellOutput(commit.shell.command, raw) ?? "")
|
||||
}
|
||||
|
||||
if (commit.toolState === "error") {
|
||||
const ctx = toolFrame(commit, raw)
|
||||
return textBody(toolScroll("final", ctx))
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
|
@ -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<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
|
||||
type PromptModel = { providerID: string; modelID: string }
|
||||
|
||||
export type RunPromptPart = NonNullable<PromptInput["parts"]>[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
|
||||
|
|
@ -93,6 +101,8 @@ export type RunPrompt = {
|
|||
command?: {
|
||||
name: string
|
||||
arguments: string
|
||||
// Catalog source of the matched slash entry ("skill" routes to session.skill).
|
||||
source?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,12 +119,10 @@ export type RunAgent = {
|
|||
hidden: boolean
|
||||
}
|
||||
|
||||
export type RunReference = NonNullable<
|
||||
Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["data"]
|
||||
>["data"][number]
|
||||
export type RunReference = ReferenceListOutput["data"][number]
|
||||
|
||||
export type RunInput = {
|
||||
sdk: OpencodeClient
|
||||
sdk: OpenCodeClient
|
||||
directory: string
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
|
|
@ -280,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[]
|
||||
|
|
@ -310,6 +322,7 @@ export type FooterEvent =
|
|||
| {
|
||||
type: "model"
|
||||
model: string
|
||||
selection: NonNullable<RunInput["model"]>
|
||||
}
|
||||
| {
|
||||
type: "turn.send"
|
||||
|
|
@ -339,11 +352,14 @@ export type FooterEvent =
|
|||
state: FooterSubagentState
|
||||
}
|
||||
|
||||
export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
export type PermissionReply = Omit<Parameters<OpenCodeClient["permission"]["reply"]>[0], "sessionID">
|
||||
|
||||
export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
|
||||
export type QuestionReply = {
|
||||
requestID: string
|
||||
answers: string[][]
|
||||
}
|
||||
|
||||
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
|
||||
export type QuestionReject = Omit<Parameters<OpenCodeClient["question"]["reject"]>[0], "sessionID">
|
||||
|
||||
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
|
||||
|
||||
27
packages/cli/src/mini/ui.ts
Normal file
27
packages/cli/src/mini/ui.ts
Normal file
|
|
@ -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"
|
||||
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
|
||||
|
||||
function modelKey(provider: string, model: string): string {
|
||||
134
packages/cli/src/server-process.ts
Normal file
134
packages/cli/src/server-process.ts
Normal file
|
|
@ -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(path.basename(service.file, ".json") + "-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<void>((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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { Effect, FileSystem, Schema } from "effect"
|
|||
import { randomBytes } from "crypto"
|
||||
import path from "path"
|
||||
|
||||
// The CLI's service configuration file, plus the ServiceOptions binding that
|
||||
// The CLI's service configuration file, plus the Service.Options binding that
|
||||
// points the client package's service operations at this CLI: which
|
||||
// registration file (by channel), which version, and how to spawn opencode.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,21 @@ 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<string>
|
||||
}
|
||||
|
||||
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(),
|
||||
env: { OPENCODE_SERVER_PASSWORD: password },
|
||||
// Explicit entry wins over anything inherited, so a user-exported
|
||||
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
|
||||
env: { OPENCODE_PASSWORD: password },
|
||||
extendEnv: true,
|
||||
// The server treats EOF on this pipe as the end of its ownership lease.
|
||||
// The OS closes it even when the TUI is killed before Effect finalizers run.
|
||||
|
|
@ -26,17 +34,21 @@ 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))
|
||||
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
|
||||
return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid }
|
||||
},
|
||||
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
||||
)
|
||||
|
||||
export function transport(options: Options = {}) {
|
||||
return makeTransport(options)
|
||||
}
|
||||
|
||||
export * as Standalone from "./standalone"
|
||||
|
|
|
|||
|
|
@ -5,11 +5,16 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { Global } from "@opencode-ai/core/global"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import type { Transport } from "@opencode-ai/client/effect"
|
||||
import type { Service } from "@opencode-ai/client/effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Args } from "@opencode-ai/tui/context/args"
|
||||
|
||||
export function runTui(transport: Transport, args: Args, discover?: () => Promise<Transport>) {
|
||||
export function runTui(
|
||||
transport: Service.Transport,
|
||||
args: Args,
|
||||
discover?: () => Promise<Service.Transport>,
|
||||
reload?: () => Promise<void>,
|
||||
) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
let disposeSlots: (() => void) | undefined
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -33,6 +38,7 @@ export function runTui(transport: Transport, args: Args, discover?: () => Promis
|
|||
}
|
||||
}
|
||||
: undefined,
|
||||
reload,
|
||||
args,
|
||||
config,
|
||||
pluginHost: {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ await Effect.runPromise(
|
|||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* Standalone.transport()
|
||||
console.log(`${transport.pid} ${transport.url}`)
|
||||
const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers }))
|
||||
console.log(`${transport.pid} ${transport.url} ${response.status}`)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
142
packages/cli/test/mini.test.ts
Normal file
142
packages/cli/test/mini.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "node:path"
|
||||
import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini"
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
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("preserves a run failure exit code", async () => {
|
||||
let modelRequests = 0
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health")
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
|
||||
if (url.pathname === "/api/model") {
|
||||
modelRequests++
|
||||
return Response.json({
|
||||
location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
|
||||
data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
|
||||
})
|
||||
}
|
||||
return new Response(undefined, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await cli([
|
||||
"run",
|
||||
"--server",
|
||||
server.url.toString(),
|
||||
"--dir",
|
||||
process.cwd(),
|
||||
"--model",
|
||||
"definitely/missing",
|
||||
"hi",
|
||||
])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("reports pre-admission errors as JSON", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await cli(["run", "--format", "json", "--server", server.url.toString(), "hi"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
type: "error",
|
||||
sessionID: "",
|
||||
error: { type: "unknown", message: expect.stringContaining("requires") },
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -4,18 +4,19 @@ import path from "node:path"
|
|||
test("standalone server exits when its owner is killed", async () => {
|
||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
env: process.env,
|
||||
env: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" },
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
|
||||
const [rawPID, url] = line?.split(" ") ?? []
|
||||
const [rawPID, url, status] = line?.split(" ") ?? []
|
||||
const pid = Number(rawPID)
|
||||
|
||||
try {
|
||||
expect(pid).toBeGreaterThan(0)
|
||||
expect(url).toStartWith("http://127.0.0.1:")
|
||||
expect(status).toBe("200")
|
||||
expect(running(pid)).toBe(true)
|
||||
|
||||
owner.kill("SIGKILL")
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/client",
|
||||
"private": true,
|
||||
"version": "1.17.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/anomalyco/opencode.git",
|
||||
"directory": "packages/client"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./promise": "./src/promise/index.ts",
|
||||
"./effect": "./src/effect/index.ts"
|
||||
"./promise/api": "./src/promise/api.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/api": "./src/effect/api.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build-package.ts",
|
||||
"generate": "bun run script/build.ts",
|
||||
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated",
|
||||
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
|
||||
"test": "bun test --timeout 5000",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
|
@ -28,9 +42,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
|
|
|
|||
9
packages/client/script/build-package.ts
Normal file
9
packages/client/script/build-package.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
|
|
@ -32,8 +32,8 @@ await Effect.runPromise(
|
|||
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)),
|
||||
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
|
||||
),
|
||||
],
|
||||
{ concurrency: 3, discard: true },
|
||||
|
|
|
|||
45
packages/client/script/publish.ts
Normal file
45
packages/client/script/publish.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
const originalText = await Bun.file("package.json").text()
|
||||
const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
|
||||
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
|
||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run build`
|
||||
pkg.exports = Object.fromEntries(
|
||||
Object.entries(pkg.exports).map(([key, value]) => {
|
||||
if (typeof value !== "string") return [key, value]
|
||||
return [
|
||||
key,
|
||||
{
|
||||
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
|
||||
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
|
||||
} finally {
|
||||
await Bun.write("package.json", originalText)
|
||||
await rm(tarball, { force: true })
|
||||
}
|
||||
8
packages/client/src/effect/api.ts
Normal file
8
packages/client/src/effect/api.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { ModelApi, ProviderApi } from "./api/api.js"
|
||||
|
||||
export type * from "./api/api.js"
|
||||
|
||||
export interface CatalogApi<E = never> {
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly model: ModelApi<E>
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import type { Effect, Stream } from "effect"
|
||||
import type { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import type { ClientApi } from "@opencode-ai/protocol/client"
|
||||
import type { ClientApi } from "../../contract"
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||
type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never
|
||||
|
|
@ -153,96 +153,105 @@ export type Endpoint4_11Input = {
|
|||
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
|
||||
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
|
||||
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
|
||||
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
||||
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
|
||||
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
export type Endpoint4_14Input = {
|
||||
readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_14Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_14Request["payload"]["files"]
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
|
||||
export type Endpoint4_12Input = {
|
||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_12Request["payload"]["id"]
|
||||
readonly command: Endpoint4_12Request["payload"]["command"]
|
||||
}
|
||||
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
|
||||
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
|
||||
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
||||
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
|
||||
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
export type Endpoint4_15Input = {
|
||||
readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_15Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_15Request["payload"]["files"]
|
||||
}
|
||||
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
|
||||
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
|
||||
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
|
||||
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
|
||||
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
|
||||
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||
export type Endpoint4_18Output = EffectValue<
|
||||
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
||||
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
export type Endpoint4_19Output = EffectValue<
|
||||
ReturnType<RawClient["server.session"]["session.context.entry.list"]>
|
||||
>["data"]
|
||||
export type SessionListContextEntriesOperation<E = never> = (
|
||||
input: Endpoint4_18Input,
|
||||
) => Effect.Effect<Endpoint4_18Output, E>
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
||||
export type Endpoint4_19Input = {
|
||||
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_19Request["params"]["key"]
|
||||
readonly value: Endpoint4_19Request["payload"]["value"]
|
||||
}
|
||||
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
|
||||
export type SessionPutContextEntryOperation<E = never> = (
|
||||
input: Endpoint4_19Input,
|
||||
) => Effect.Effect<Endpoint4_19Output, E>
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
||||
export type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_20Request["params"]["key"]
|
||||
readonly value: Endpoint4_20Request["payload"]["value"]
|
||||
}
|
||||
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
|
||||
export type SessionRemoveContextEntryOperation<E = never> = (
|
||||
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
|
||||
export type SessionPutContextEntryOperation<E = never> = (
|
||||
input: Endpoint4_20Input,
|
||||
) => Effect.Effect<Endpoint4_20Output, E>
|
||||
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
||||
export type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_21Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_21Request["query"]["follow"]
|
||||
readonly key: Endpoint4_21Request["params"]["key"]
|
||||
}
|
||||
export type Endpoint4_21Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint4_21Input) => Stream.Stream<Endpoint4_21Output, E>
|
||||
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
|
||||
export type SessionRemoveContextEntryOperation<E = never> = (
|
||||
input: Endpoint4_21Input,
|
||||
) => Effect.Effect<Endpoint4_21Output, E>
|
||||
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
|
||||
export type Endpoint4_22Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_22Input) => Effect.Effect<Endpoint4_22Output, E>
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
export type Endpoint4_22Input = {
|
||||
readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_22Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_22Request["query"]["follow"]
|
||||
}
|
||||
export type Endpoint4_22Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint4_22Input) => Stream.Stream<Endpoint4_22Output, E>
|
||||
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
|
||||
export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
|
||||
export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E>
|
||||
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint4_24Input = {
|
||||
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_24Request["params"]["messageID"]
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
|
||||
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
|
||||
|
||||
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint4_25Input = {
|
||||
readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_25Request["params"]["messageID"]
|
||||
}
|
||||
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
|
||||
export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
|
|
@ -257,6 +266,7 @@ export interface SessionApi<E = never> {
|
|||
readonly command: SessionCommandOperation<E>
|
||||
readonly skill: SessionSkillOperation<E>
|
||||
readonly synthetic: SessionSyntheticOperation<E>
|
||||
readonly shell: SessionShellOperation<E>
|
||||
readonly compact: SessionCompactOperation<E>
|
||||
readonly wait: SessionWaitOperation<E>
|
||||
readonly revertStage: SessionRevertStageOperation<E>
|
||||
|
|
@ -440,20 +450,24 @@ export interface CredentialApi<E = never> {
|
|||
readonly remove: CredentialRemoveOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint12_0Request = Parameters<RawClient["server.project"]["project.current"]>[0]
|
||||
export type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
|
||||
export type Endpoint12_0Output = EffectValue<ReturnType<RawClient["server.project"]["project.current"]>>
|
||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint12_0Input) => Effect.Effect<Endpoint12_0Output, E>
|
||||
export type Endpoint12_0Output = EffectValue<ReturnType<RawClient["server.project"]["project.list"]>>
|
||||
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint12_0Output, E>
|
||||
|
||||
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
|
||||
export type Endpoint12_1Input = {
|
||||
readonly projectID: Endpoint12_1Request["params"]["projectID"]
|
||||
readonly location?: Endpoint12_1Request["query"]["location"]
|
||||
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.current"]>[0]
|
||||
export type Endpoint12_1Input = { readonly location?: Endpoint12_1Request["query"]["location"] }
|
||||
export type Endpoint12_1Output = EffectValue<ReturnType<RawClient["server.project"]["project.current"]>>
|
||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
|
||||
|
||||
type Endpoint12_2Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
|
||||
export type Endpoint12_2Input = {
|
||||
readonly projectID: Endpoint12_2Request["params"]["projectID"]
|
||||
readonly location?: Endpoint12_2Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint12_1Output = EffectValue<ReturnType<RawClient["server.project"]["project.directories"]>>
|
||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
|
||||
export type Endpoint12_2Output = EffectValue<ReturnType<RawClient["server.project"]["project.directories"]>>
|
||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint12_2Input) => Effect.Effect<Endpoint12_2Output, E>
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly list: ProjectListOperation<E>
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
readonly directories: ProjectDirectoriesOperation<E>
|
||||
}
|
||||
|
|
@ -852,6 +866,13 @@ export interface VcsApi<E = never> {
|
|||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
|
||||
export type DebugLocationOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
export interface DebugApi<E = never> {
|
||||
readonly location: DebugLocationOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly location: LocationApi<E>
|
||||
|
|
@ -878,4 +899,5 @@ export interface AppApi<E = never> {
|
|||
readonly reference: ReferenceApi<E>
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
}
|
||||
|
|
@ -208,23 +208,35 @@ const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11I
|
|||
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
|
||||
type Endpoint4_12Input = {
|
||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint4_12Request["payload"]["id"]
|
||||
readonly command: Endpoint4_12Request["payload"]["command"]
|
||||
}
|
||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint4_14Input = {
|
||||
readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_14Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_14Request["payload"]["files"]
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint4_15Input = {
|
||||
readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_15Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_15Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
|
|
@ -233,61 +245,61 @@ const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14I
|
|||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
|
||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
||||
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
||||
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
||||
type Endpoint4_19Input = {
|
||||
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_19Request["params"]["key"]
|
||||
readonly value: Endpoint4_19Request["payload"]["value"]
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
||||
type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_20Request["params"]["key"]
|
||||
readonly value: Endpoint4_20Request["payload"]["value"]
|
||||
}
|
||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
raw["session.context.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
||||
type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_20Request["params"]["key"]
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
||||
type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly key: Endpoint4_21Request["params"]["key"]
|
||||
}
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
||||
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_21Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_21Request["query"]["follow"]
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
||||
type Endpoint4_22Input = {
|
||||
readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_22Request["query"]["after"]
|
||||
readonly follow?: Endpoint4_22Request["query"]["follow"]
|
||||
}
|
||||
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
||||
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
|
|
@ -298,22 +310,22 @@ const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21I
|
|||
),
|
||||
)
|
||||
|
||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
|
||||
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
|
||||
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
|
||||
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_24Input = {
|
||||
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_24Request["params"]["messageID"]
|
||||
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_25Input = {
|
||||
readonly sessionID: Endpoint4_25Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_25Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
|
||||
const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
|
|
@ -332,19 +344,20 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
|||
command: Endpoint4_9(raw),
|
||||
skill: Endpoint4_10(raw),
|
||||
synthetic: Endpoint4_11(raw),
|
||||
compact: Endpoint4_12(raw),
|
||||
wait: Endpoint4_13(raw),
|
||||
revertStage: Endpoint4_14(raw),
|
||||
revertClear: Endpoint4_15(raw),
|
||||
revertCommit: Endpoint4_16(raw),
|
||||
context: Endpoint4_17(raw),
|
||||
listContextEntries: Endpoint4_18(raw),
|
||||
putContextEntry: Endpoint4_19(raw),
|
||||
removeContextEntry: Endpoint4_20(raw),
|
||||
log: Endpoint4_21(raw),
|
||||
interrupt: Endpoint4_22(raw),
|
||||
background: Endpoint4_23(raw),
|
||||
message: Endpoint4_24(raw),
|
||||
shell: Endpoint4_12(raw),
|
||||
compact: Endpoint4_13(raw),
|
||||
wait: Endpoint4_14(raw),
|
||||
revertStage: Endpoint4_15(raw),
|
||||
revertClear: Endpoint4_16(raw),
|
||||
revertCommit: Endpoint4_17(raw),
|
||||
context: Endpoint4_18(raw),
|
||||
listContextEntries: Endpoint4_19(raw),
|
||||
putContextEntry: Endpoint4_20(raw),
|
||||
removeContextEntry: Endpoint4_21(raw),
|
||||
log: Endpoint4_22(raw),
|
||||
interrupt: Endpoint4_23(raw),
|
||||
background: Endpoint4_24(raw),
|
||||
message: Endpoint4_25(raw),
|
||||
})
|
||||
|
||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
|
|
@ -531,25 +544,29 @@ const Endpoint11_1 = (raw: RawClient["server.credential"]) => (input: Endpoint11
|
|||
|
||||
const adaptGroup11 = (raw: RawClient["server.credential"]) => ({ update: Endpoint11_0(raw), remove: Endpoint11_1(raw) })
|
||||
|
||||
type Endpoint12_0Request = Parameters<RawClient["server.project"]["project.current"]>[0]
|
||||
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
|
||||
const Endpoint12_0 = (raw: RawClient["server.project"]) => (input?: Endpoint12_0Input) =>
|
||||
const Endpoint12_0 = (raw: RawClient["server.project"]) => () =>
|
||||
raw["project.list"]({}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.current"]>[0]
|
||||
type Endpoint12_1Input = { readonly location?: Endpoint12_1Request["query"]["location"] }
|
||||
const Endpoint12_1 = (raw: RawClient["server.project"]) => (input?: Endpoint12_1Input) =>
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
|
||||
type Endpoint12_1Input = {
|
||||
readonly projectID: Endpoint12_1Request["params"]["projectID"]
|
||||
readonly location?: Endpoint12_1Request["query"]["location"]
|
||||
type Endpoint12_2Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
|
||||
type Endpoint12_2Input = {
|
||||
readonly projectID: Endpoint12_2Request["params"]["projectID"]
|
||||
readonly location?: Endpoint12_2Request["query"]["location"]
|
||||
}
|
||||
const Endpoint12_1 = (raw: RawClient["server.project"]) => (input: Endpoint12_1Input) =>
|
||||
const Endpoint12_2 = (raw: RawClient["server.project"]) => (input: Endpoint12_2Input) =>
|
||||
raw["project.directories"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup12 = (raw: RawClient["server.project"]) => ({
|
||||
current: Endpoint12_0(raw),
|
||||
directories: Endpoint12_1(raw),
|
||||
list: Endpoint12_0(raw),
|
||||
current: Endpoint12_1(raw),
|
||||
directories: Endpoint12_2(raw),
|
||||
})
|
||||
|
||||
type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
|
||||
|
|
@ -1030,6 +1047,11 @@ const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input
|
|||
|
||||
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) })
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
location: adaptGroup1(raw["server.location"]),
|
||||
|
|
@ -1056,6 +1078,7 @@ const adaptClient = (raw: RawClient) => ({
|
|||
reference: adaptGroup22(raw["server.reference"]),
|
||||
projectCopy: adaptGroup23(raw["server.projectCopy"]),
|
||||
vcs: adaptGroup24(raw["server.vcs"]),
|
||||
debug: adaptGroup25(raw["server.debug"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
|
||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||
import type { Effect } from "effect"
|
||||
|
||||
export * from "./generated/index"
|
||||
export type {
|
||||
AgentApi,
|
||||
AppApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
export { Service } from "./service.js"
|
||||
export type { Transport, ServiceOptions } from "./service.js"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
|
|
@ -28,3 +43,4 @@ export { SessionMessage } from "@opencode-ai/schema/session-message"
|
|||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export type Transport = {
|
|||
readonly headers?: RequestInit["headers"]
|
||||
}
|
||||
|
||||
export type ServiceOptions = {
|
||||
export type Options = {
|
||||
// Absolute path to the service registration file. Defaults to
|
||||
// opencode/service.json in the XDG state directory.
|
||||
readonly file?: string
|
||||
|
|
@ -30,44 +30,58 @@ export type ServiceOptions = {
|
|||
|
||||
// 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: ServiceOptions = {}) {
|
||||
const registration = yield* read(options.file)
|
||||
if (registration === undefined) return undefined
|
||||
if (options.version !== undefined && registration.version !== options.version) return undefined
|
||||
const found = yield* probe(registration)
|
||||
return found?.transport
|
||||
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
|
||||
return yield* probe(info, options.version)
|
||||
})
|
||||
|
||||
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
||||
// version-mismatched one, and otherwise spawns the service command detached.
|
||||
export const start = Effect.fn("service.start")(function* (options: ServiceOptions = {}) {
|
||||
export const start = Effect.fn("service.start")(function* (options: Options = {}) {
|
||||
const compatible = yield* discover(options)
|
||||
if (compatible !== undefined) return compatible
|
||||
const mismatched = yield* find(options)
|
||||
if (mismatched !== undefined) yield* kill(mismatched.registration, options).pipe(Effect.ignore)
|
||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||
|
||||
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")),
|
||||
)
|
||||
})
|
||||
|
||||
export const stop = Effect.fn("service.stop")(function* (options: ServiceOptions = {}) {
|
||||
export const stop = Effect.fn("service.stop")(function* (options: Options = {}) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const existing = yield* find(options)
|
||||
if (existing !== undefined) yield* kill(existing.registration, options)
|
||||
if (existing !== undefined) yield* kill(existing.info, options)
|
||||
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
|
|
@ -80,18 +94,22 @@ function auth(password: string): RequestInit["headers"] {
|
|||
return { authorization: "Basic " + btoa("opencode:" + password) }
|
||||
}
|
||||
|
||||
const Registration = Schema.Struct({
|
||||
export const Info = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
url: Schema.String,
|
||||
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||
password: Schema.optional(Schema.String),
|
||||
})
|
||||
type Registration = typeof Registration.Type
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeHealth = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }),
|
||||
)
|
||||
const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) }))
|
||||
|
||||
// A missing or corrupt file means no valid registration; callers treat both
|
||||
// A missing or corrupt file means no valid info; callers treat both
|
||||
// the same (the registering server self-evicts, clients rediscover).
|
||||
const read = Effect.fnUntraced(function* (file?: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
|
@ -101,31 +119,42 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
|||
})
|
||||
|
||||
type LocalService = {
|
||||
readonly registration: Registration
|
||||
readonly info: Info
|
||||
readonly transport: Transport
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (registration: Registration) {
|
||||
const headers = registration.password === undefined ? undefined : auth(registration.password)
|
||||
const healthy = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/health", registration.url), {
|
||||
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
||||
const headers = info.password === undefined ? undefined : auth(info.password)
|
||||
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 body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
const health = decodeHealth(body)
|
||||
if (Option.isSome(health)) {
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
if (version !== undefined && health.value.version !== version) return undefined
|
||||
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
Option.isNone(decodeLegacyHealth(body)) ||
|
||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||
)
|
||||
if (!healthy) return undefined
|
||||
return { registration, transport: { url: registration.url, headers } } satisfies LocalService
|
||||
return undefined
|
||||
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
// able to see (and replace or stop) a server from a different version.
|
||||
const find = Effect.fnUntraced(function* (options: ServiceOptions) {
|
||||
const registration = yield* read(options.file)
|
||||
if (registration === undefined) return undefined
|
||||
return yield* probe(registration)
|
||||
const find = Effect.fnUntraced(function* (options: Options) {
|
||||
const info = yield* read(options.file)
|
||||
if (info === undefined) return undefined
|
||||
return yield* probe(info, undefined, true)
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
|
||||
|
|
@ -142,22 +171,22 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
|
|||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
function same(left: Registration, right: Registration) {
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const kill = Effect.fnUntraced(function* (info: Registration, options: ServiceOptions) {
|
||||
const kill = Effect.fnUntraced(function* (info: Info, options: Options) {
|
||||
// A stale registration may point at a PID that has since been reused by
|
||||
// another process. Only signal the PID after authenticating the server.
|
||||
const current = yield* find(options)
|
||||
if (current === undefined || !same(current.registration, info)) return
|
||||
if (current === undefined || !same(current.info, info)) return
|
||||
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* find(options)
|
||||
if (latest === undefined || !same(latest.registration, info)) return
|
||||
if (latest === undefined || !same(latest.info, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
||||
})
|
||||
|
|
|
|||
41
packages/client/src/promise/api.ts
Normal file
41
packages/client/src/promise/api.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type {
|
||||
AgentApi as EffectAgentApi,
|
||||
CommandApi as EffectCommandApi,
|
||||
EventApi as EffectEventApi,
|
||||
IntegrationApi as EffectIntegrationApi,
|
||||
ModelApi as EffectModelApi,
|
||||
PluginApi as EffectPluginApi,
|
||||
ProviderApi as EffectProviderApi,
|
||||
ReferenceApi as EffectReferenceApi,
|
||||
SessionApi as EffectSessionApi,
|
||||
SkillApi as EffectSkillApi,
|
||||
} from "../effect/api/api.js"
|
||||
import type { Effect, Stream } from "effect"
|
||||
|
||||
type PromisifyOperation<Operation> = Operation extends (
|
||||
...args: infer Args
|
||||
) => Effect.Effect<infer Success, unknown, unknown>
|
||||
? (...args: Args) => Promise<Success>
|
||||
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
|
||||
? (...args: Args) => AsyncIterable<Success>
|
||||
: Operation
|
||||
|
||||
type PromisifyApi<Api> = {
|
||||
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>
|
||||
}
|
||||
|
||||
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
|
||||
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
|
||||
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
|
||||
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
|
||||
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
|
||||
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
||||
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
||||
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
||||
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
||||
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
||||
|
||||
export interface CatalogApi {
|
||||
readonly provider: ProviderApi
|
||||
readonly model: ModelApi
|
||||
}
|
||||
|
|
@ -29,6 +29,8 @@ import type {
|
|||
SessionSkillOutput,
|
||||
SessionSyntheticInput,
|
||||
SessionSyntheticOutput,
|
||||
SessionShellInput,
|
||||
SessionShellOutput,
|
||||
SessionCompactInput,
|
||||
SessionCompactOutput,
|
||||
SessionWaitInput,
|
||||
|
|
@ -87,6 +89,7 @@ import type {
|
|||
CredentialUpdateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectDirectoriesInput,
|
||||
|
|
@ -171,6 +174,7 @@ import type {
|
|||
VcsStatusOutput,
|
||||
VcsDiffInput,
|
||||
VcsDiffOutput,
|
||||
DebugLocationOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
|
|
@ -525,6 +529,18 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
),
|
||||
shell: (input: SessionShellInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionShellOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/shell`,
|
||||
body: { id: input["id"], command: input["command"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionCompactOutput>(
|
||||
{
|
||||
|
|
@ -884,6 +900,11 @@ export function make(options: ClientOptions) {
|
|||
),
|
||||
},
|
||||
project: {
|
||||
list: (requestOptions?: RequestOptions) =>
|
||||
request<ProjectListOutput>(
|
||||
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCurrentOutput>(
|
||||
{
|
||||
|
|
@ -1434,6 +1455,19 @@ export function make(options: ClientOptions) {
|
|||
requestOptions,
|
||||
),
|
||||
},
|
||||
debug: {
|
||||
location: (requestOptions?: RequestOptions) =>
|
||||
request<DebugLocationOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/debug/location`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,16 @@
|
|||
export * from "./generated/index"
|
||||
export type { Transport } from "../effect/service.js"
|
||||
export type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
||||
|
|
|
|||
10
packages/client/test/api.types.ts
Normal file
10
packages/client/test/api.types.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Effect } from "effect"
|
||||
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
|
||||
|
||||
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
|
||||
|
||||
declare const effectClient: EffectClient
|
||||
|
||||
const effectApi: EffectApi<unknown> = effectClient
|
||||
|
||||
void effectApi
|
||||
|
|
@ -1,7 +1,17 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AbsolutePath, Agent, Event, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect/index"
|
||||
import {
|
||||
AbsolutePath,
|
||||
Agent,
|
||||
Event,
|
||||
Location,
|
||||
Model,
|
||||
OpenCode,
|
||||
Prompt,
|
||||
Session,
|
||||
SessionMessage,
|
||||
} from "../src/effect/index"
|
||||
|
||||
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
|
||||
|
||||
|
|
@ -23,7 +33,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
|
|||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(
|
||||
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||
`data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` +
|
||||
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
|
|
@ -35,10 +45,10 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
|
|||
return yield* client.event.subscribe().pipe(Stream.runCollect)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
||||
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
|
||||
const durable = events[1]
|
||||
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
|
||||
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
|
||||
if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
|
||||
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
|
||||
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||
})
|
||||
|
||||
|
|
@ -149,8 +159,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||
expect(result.context).toEqual([])
|
||||
expect(logQueries[0]).toEqual({ after: "0" })
|
||||
const logged = Array.from(result.log)
|
||||
expect(logged.map((item) => item.type)).toEqual(["session.next.model.switched", "log.synced"])
|
||||
expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).toBe(
|
||||
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
|
||||
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
|
||||
1_717_171_717_000,
|
||||
)
|
||||
expect(logged.at(-1)).toEqual(synced)
|
||||
|
|
@ -217,12 +227,11 @@ const modelSwitchedMessage = {
|
|||
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
type: "session.next.model.switched",
|
||||
created: 1_717_171_717_000,
|
||||
type: "session.model.selected",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
data: {
|
||||
timestamp: 1_717_171_717_000,
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_model",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ test("exposes every standard HTTP API group", () => {
|
|||
"reference",
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.message)).toEqual(["list"])
|
||||
expect(Object.keys(client.integration)).toEqual([
|
||||
"list",
|
||||
|
|
@ -151,7 +153,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
|
|||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` +
|
||||
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
|
|
@ -159,8 +161,8 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
|
|||
const events = []
|
||||
for await (const event of client.event.subscribe()) events.push(event)
|
||||
|
||||
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
||||
expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
|
|
@ -328,12 +330,11 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
|
|||
|
||||
const modelSwitchedEvent = {
|
||||
id: "evt_model",
|
||||
type: "session.next.model.switched",
|
||||
created: 1_717_171_717_000,
|
||||
type: "session.model.selected",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
data: {
|
||||
timestamp: 1_717_171_717_000,
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_model",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
},
|
||||
}
|
||||
|
|
|
|||
11
packages/client/tsconfig.build.json
Normal file
11
packages/client/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowJs": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src"]
|
||||
|
|
|
|||
23
packages/codemode/AGENTS.md
Normal file
23
packages/codemode/AGENTS.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# @opencode-ai/codemode
|
||||
|
||||
- This local package owns confined execution over explicit schema-described tools. Applications own authorization, persistence, external authority, and tool-specific delivery semantics.
|
||||
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
|
||||
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
|
||||
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
- Generate an operation only when its transport semantics are supported; otherwise return a precise `skipped` reason.
|
||||
- Never guess parameter serialization or malformed security semantics. Unsupported serialization is skipped and malformed security fails closed.
|
||||
- Render unresolved schema constructs as `unknown`, never as invented TypeScript names.
|
||||
- Keep network reads bounded and map expected encoding, transport, and decoding failures to model-safe `ToolError` values.
|
||||
- Test supported behavior directly; do not reproduce adapter algorithms in tests.
|
||||
|
||||
## Future Design Notes
|
||||
|
||||
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
|
||||
- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
|
||||
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
|
||||
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
|
||||
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
|
||||
- If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool.
|
||||
361
packages/codemode/README.md
Normal file
361
packages/codemode/README.md
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
# @opencode-ai/codemode
|
||||
|
||||
Effect-native confined code execution over explicit, schema-described tools.
|
||||
|
||||
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
|
||||
|
||||
The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
|
||||
|
||||
```ts
|
||||
// One execution
|
||||
yield * CodeMode.execute({ tools, code })
|
||||
|
||||
// A reusable runtime
|
||||
const runtime = CodeMode.make({ tools, limits })
|
||||
yield * runtime.execute(code)
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Within this workspace:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@opencode-ai/codemode": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`:
|
||||
|
||||
```ts
|
||||
import { CodeMode, Tool } from "@opencode-ai/codemode"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const lookupOrder = Tool.make({
|
||||
description: "Look up an order by ID",
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
|
||||
run: ({ id }) => Effect.succeed({ id, status: "open" }),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
orders: {
|
||||
lookup: lookupOrder,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result =
|
||||
yield *
|
||||
runtime.execute(`
|
||||
const order = await tools.orders.lookup({ id: "order_42" })
|
||||
return { id: order.id, needsAttention: order.status !== "complete" }
|
||||
`)
|
||||
```
|
||||
|
||||
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
|
||||
|
||||
Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well.
|
||||
|
||||
## API
|
||||
|
||||
### `Tool.make`
|
||||
|
||||
```ts
|
||||
const tool = Tool.make({
|
||||
description,
|
||||
input, // Effect Schema (validating) or JSON Schema (render-only)
|
||||
output, // optional; same choice
|
||||
run,
|
||||
})
|
||||
```
|
||||
|
||||
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary).
|
||||
|
||||
`output` is optional. Without it the tool's signature advertises `Promise<unknown>` and the host result is exposed as-is.
|
||||
|
||||
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
|
||||
|
||||
Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
|
||||
|
||||
### `CodeMode.execute`
|
||||
|
||||
Use `CodeMode.execute` for a single execution:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
CodeMode.execute({
|
||||
tools: { orders: { lookup: lookupOrder } },
|
||||
code: `return await tools.orders.lookup({ id: "order_42" })`,
|
||||
limits: { maxToolCalls: 10 },
|
||||
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
|
||||
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
|
||||
})
|
||||
```
|
||||
|
||||
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
|
||||
|
||||
### `CodeMode.make`
|
||||
|
||||
Use `CodeMode.make` when the tool set and execution policy are reused:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({
|
||||
tools: { orders: { lookup: lookupOrder } },
|
||||
limits: { timeoutMs: 30_000 },
|
||||
})
|
||||
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
runtime.execute(source) // CodeMode.Result
|
||||
```
|
||||
|
||||
`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
|
||||
|
||||
All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
|
||||
|
||||
### Results
|
||||
|
||||
```ts
|
||||
type Result = Success | Failure
|
||||
|
||||
interface Success {
|
||||
readonly ok: true
|
||||
readonly value: CodeMode.DataValue
|
||||
readonly logs?: ReadonlyArray<string>
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
|
||||
}
|
||||
|
||||
interface Failure {
|
||||
readonly ok: false
|
||||
readonly error: CodeMode.Diagnostic
|
||||
readonly logs?: ReadonlyArray<string>
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
|
||||
}
|
||||
```
|
||||
|
||||
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (see Execution Limits).
|
||||
|
||||
### Tool-call hooks
|
||||
|
||||
`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately.
|
||||
|
||||
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
|
||||
|
||||
```ts
|
||||
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const api = OpenAPI.fromSpec({
|
||||
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
|
||||
auth: {
|
||||
resolve: ({ name, scopes, operation }) =>
|
||||
name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
||||
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
|
||||
```
|
||||
|
||||
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
|
||||
|
||||
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
|
||||
|
||||
## Discovery
|
||||
|
||||
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
|
||||
|
||||
The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({
|
||||
tools,
|
||||
discovery: { maxInlineCatalogTokens: 6_000 },
|
||||
})
|
||||
```
|
||||
|
||||
The budget must be a non-negative safe integer.
|
||||
|
||||
The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial:
|
||||
|
||||
```ts
|
||||
const matches = await tools.$codemode.search({
|
||||
query: "order status",
|
||||
namespace: "orders", // optional: scope to one top-level namespace
|
||||
limit: 10,
|
||||
})
|
||||
```
|
||||
|
||||
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10).
|
||||
|
||||
Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form.
|
||||
|
||||
```ts
|
||||
tools.github.list_issues(input: {
|
||||
/** Repository owner */
|
||||
owner: string
|
||||
/** Cursor from the previous response's pageInfo */
|
||||
after?: string
|
||||
/**
|
||||
* Results per page
|
||||
* @default 30
|
||||
*/
|
||||
perPage?: number
|
||||
}): Promise<unknown>
|
||||
```
|
||||
|
||||
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
|
||||
|
||||
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise<unknown>` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders - never a real or fabricated tool name.
|
||||
|
||||
A host cannot define its own `$codemode` top-level namespace.
|
||||
|
||||
## Supported Programs
|
||||
|
||||
CodeMode executes a deliberately bounded JavaScript subset. It supports:
|
||||
|
||||
- Plain data literals, property access, assignment, and destructuring.
|
||||
- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`.
|
||||
- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring.
|
||||
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
|
||||
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
|
||||
- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
|
||||
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported.
|
||||
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
|
||||
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
|
||||
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
|
||||
|
||||
Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
|
||||
|
||||
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
|
||||
|
||||
CodeMode is an orchestration language, not a general JavaScript runtime.
|
||||
|
||||
## Execution Limits
|
||||
|
||||
The limits are exactly three knobs:
|
||||
|
||||
| Limit | Default | Bounds |
|
||||
| ---------------- | -------------------: | -------------------------------------------------------------------- |
|
||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
|
||||
|
||||
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
|
||||
|
||||
Pass only the overrides you need:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({
|
||||
tools,
|
||||
limits: {
|
||||
maxToolCalls: 20,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
|
||||
|
||||
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`.
|
||||
|
||||
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded.
|
||||
|
||||
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Failures are data:
|
||||
|
||||
| Kind | Meaning |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `ParseError` | Source is empty or cannot be parsed. |
|
||||
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
|
||||
| `UnknownTool` | A program referenced a tool the host did not provide. |
|
||||
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
|
||||
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
|
||||
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
|
||||
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
|
||||
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
|
||||
| `ToolFailure` | A tool refused or failed. |
|
||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||
|
||||
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
|
||||
|
||||
```ts
|
||||
import { toolError } from "@opencode-ai/codemode"
|
||||
|
||||
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
|
||||
```
|
||||
|
||||
Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
|
||||
|
||||
## Authority Boundary
|
||||
|
||||
CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do.
|
||||
|
||||
The host owns:
|
||||
|
||||
- Authentication and authorization.
|
||||
- Tool selection and immutable scope.
|
||||
- Credentials and network clients.
|
||||
- Persistence, idempotency, approval, and durable side effects.
|
||||
- Logging and redaction policy.
|
||||
|
||||
CodeMode owns:
|
||||
|
||||
- Parsing and interpreting the supported subset without `eval`.
|
||||
- Schema boundaries around tool calls.
|
||||
- Plain-data copying and blocked prototype members.
|
||||
- Resource limits, call accounting, and normalized diagnostics.
|
||||
- Model-facing tool discovery and instructions.
|
||||
|
||||
A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it.
|
||||
|
||||
## Laws
|
||||
|
||||
The public contract is guided by these equivalences:
|
||||
|
||||
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
|
||||
- A tool implementation is not invoked unless its input has decoded successfully.
|
||||
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
|
||||
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
|
||||
- Host interruption remains interruption rather than a `CodeMode.Failure`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Generic permission prompts or approval workflows.
|
||||
- Durable pause/resume, replay, or storage adapters.
|
||||
- Exactly-once external side effects.
|
||||
- Application authorization or product policy.
|
||||
- A filesystem or process sandbox for arbitrary JavaScript.
|
||||
- Compatibility with the full JavaScript language or npm ecosystem.
|
||||
|
||||
Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools.
|
||||
|
||||
## Testing
|
||||
|
||||
From the package directory:
|
||||
|
||||
```sh
|
||||
bun test
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption.
|
||||
1225
packages/codemode/codemode.md
Normal file
1225
packages/codemode/codemode.md
Normal file
File diff suppressed because it is too large
Load diff
26
packages/codemode/package.json
Normal file
26
packages/codemode/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "0.0.1",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue