Compare commits

..

32 Commits

Author SHA1 Message Date
Kit Langton ca668a7ef8 refactor(core): remove unused session watermarks 2026-07-06 13:50:56 -04:00
Kit Langton 142ce54e72 refactor(core): unify live event subscriptions 2026-07-06 13:40:19 -04:00
Kit Langton 2830176972 fix(plugin): reload late SDK plugins (#35576) 2026-07-06 13:36:19 -04:00
Dax Raad 06dcf3f221 fix(tui): remove committed undo messages 2026-07-06 13:08:25 -04:00
Dax Raad f9442e7107 test(opencode): update active session mock 2026-07-06 13:01:36 -04:00
Dax Raad ff499c3603 refactor(api): remove watermark sync surface 2026-07-06 13:01:36 -04:00
Aiden Cline 4cee5bd824 refactor(codemode): split interpreter and stdlib (#35572) 2026-07-06 12:01:14 -05:00
Dax Raad f406fed924 fix(cli): ignore version mismatch on reconnect 2026-07-06 12:35:07 -04:00
Aiden Cline 5b2d90a441 feat(codemode): expand standard library support (#35566) 2026-07-06 10:36:02 -05:00
opencode-agent[bot] 04e8801f5b fix(tui): soften interrupted step styling (#35466)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-06 09:30:55 -05:00
Aiden Cline 7f008df79d fix(codemode): improve prompting (#35509) 2026-07-06 09:19:04 -05:00
Simon Klee 175d200e53 V2 opentui upgrade (#35544)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-06 15:34:57 +02:00
Simon Klee 41d11a8a89 cli: demote version mismatch to warning for now (#35548) 2026-07-06 14:39:58 +02:00
Simon Klee 0bba8c780a cli/mini: fix run failure handling (#35539) 2026-07-06 12:34:44 +02:00
Simon Klee 32cf36de9d mini migrate to v2 (#35526) 2026-07-06 11:03:30 +02:00
Simon Klee fb75ea2cf6 mini: fix default model (#35515) 2026-07-06 09:20:23 +02:00
Dax Raad 147da5d278 test(opencode): update project service stub 2026-07-06 01:32:58 -04:00
Dax Raad eed23d8ee9 feat(api): publish v2 reference 2026-07-06 01:29:08 -04:00
Dax Raad e6e951b252 docs: rebuild v2 documentation 2026-07-06 00:58:00 -04:00
Aiden Cline 4e019ba5d9 fix(core): expose MCP output schemas to code mode (#35502) 2026-07-05 23:12:45 -05:00
Aiden Cline 50a762e7b9 fix(mcp): preserve metadata across paginated tools (#35500) 2026-07-05 22:51:08 -05:00
Aiden Cline 780c99bc2e refactor(core): route questions through forms (#35422) 2026-07-05 22:28:05 -05:00
Aiden Cline 5bcf8d5a0b feat(tui): render session forms (#35421) 2026-07-05 19:35:30 -05:00
Dax Raad 652e8ff3fb fix(client): resolve codegen workspace version 2026-07-05 18:31:15 -04:00
Dax Raad 7b88de47c3 chore(client): publish client package 2026-07-05 18:21:23 -04:00
Dax Raad 33cb536879 feat(plugin): publish generation updates 2026-07-05 17:21:39 -04:00
Dax Raad 6ffecf9345 fix(core): reload local plugins on file changes 2026-07-05 16:54:17 -04:00
Dax Raad 08741f6b93 fix(core): scope observability to server 2026-07-05 16:22:04 -04:00
Dax Raad baacb2e776 fix(core): tolerate invalid OTLP configuration 2026-07-05 16:14:02 -04:00
Dax Raad a75978815a feat(server): add loaded locations debug endpoint 2026-07-05 15:59:44 -04:00
Dax Raad c3d26c4912 feat(core): improve runtime observability 2026-07-05 15:59:44 -04:00
Dax Raad a9b7bd9e2f feat(core): manage configurable plugin generations 2026-07-05 15:59:44 -04:00
342 changed files with 39550 additions and 13345 deletions
+480 -617
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -42,9 +42,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
+1 -3
View File
@@ -6,9 +6,8 @@ const path = require("path")
const os = require("os")
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
const restartExitCode = 75
function run(target, restarted = false) {
function run(target) {
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
child.on("error", (error) => {
console.error(error.message)
@@ -26,7 +25,6 @@ function run(target, restarted = false) {
child.on("exit", (code, signal) => {
for (const forwardedSignal of forwardedSignals) process.removeListener(forwardedSignal, forwarders[forwardedSignal])
if (signal) return process.kill(process.pid, signal)
if (code === restartExitCode && !restarted) return run(target, true)
process.exit(typeof code === "number" ? code : 0)
})
}
+22 -1
View File
@@ -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:*",
+105
View File
@@ -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: [
+12 -34
View File
@@ -8,8 +8,6 @@ import { ServiceConfig } from "../../services/service-config"
import { Standalone } from "../../services/standalone"
import { Updater } from "../../services/updater"
const restartExitCode = 75
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
@@ -24,7 +22,9 @@ export default Runtime.handler(Commands, (input) =>
const password = yield* Env.password
const explicit = {
url: server,
headers: password ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } : undefined,
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.
@@ -54,13 +54,16 @@ export default Runtime.handler(Commands, (input) =>
// retry the same address; for the managed service discovery re-reads the
// registration and may start a replacement.
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
const discover = serviceOptions
// Only startup enforces the CLI version. A reconnect must accept a server
// replaced by another client or the two clients will restart it forever.
const reconnectOptions = serviceOptions ? { ...serviceOptions, version: undefined } : undefined
const discover = reconnectOptions
? () =>
Effect.runPromise(
Effect.gen(function* () {
const found = yield* Service.discover(serviceOptions)
return found ?? (yield* Service.start(serviceOptions))
}).pipe(Effect.catchIf(versionMismatch, restart), Effect.provide(NodeFileSystem.layer)),
const found = yield* Service.discover(reconnectOptions)
return found ?? (yield* Service.start(reconnectOptions))
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: () => Promise.resolve(transport)
// Restart the managed service in place; start() resolves once the
@@ -76,36 +79,11 @@ export default Runtime.handler(Commands, (input) =>
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: undefined
return yield* runTui(
yield* runTui(
transport,
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
discover,
reload,
() => {
process.exitCode = restartExitCode
},
)
}).pipe(
Effect.catchIf(versionMismatch, (error) =>
restart(error).pipe(
Effect.tap(() =>
Effect.sync(() => {
process.exitCode = restartExitCode
}),
),
Effect.asVoid,
),
),
),
}),
)
function restart(error: Service.VersionMismatchError) {
return Effect.logInfo("restarting stale client", {
clientVersion: error.clientVersion,
serverVersion: error.serverVersion,
}).pipe(Effect.as({ restart: true as const }))
}
function versionMismatch(error: unknown): error is Service.VersionMismatchError {
return error instanceof Service.VersionMismatchError
}
@@ -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
View 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,
}),
)
}),
)
+8 -146
View File
@@ -1,154 +1,16 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config"
import { Updater } from "../../services/updater"
import { randomBytes, randomUUID } from "crypto"
import path from "path"
import { ServerProcess } from "../../server-process"
export default Runtime.handler(
Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) {
if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const standalonePassword = yield* Env.password
// Keep the lease credential out of the environment inherited by any
// process this server spawns.
if (input.stdio) {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = input.service ? yield* ServiceConfig.read() : {}
const password = input.service
? yield* ServiceConfig.password()
: standalonePassword
? Redacted.value(standalonePassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
const port = Option.isSome(input.port)
? input.port
: config.port === undefined
? Option.none<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.
// Written and read through Service.Info so the file the server registers is
// provably the contract clients discover with.
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
const fs = yield* FileSystem.FileSystem
const { file } = yield* ServiceConfig.options()
const id = randomUUID()
const secret = yield* ServiceConfig.password()
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const encoded = yield* encodeInfo({
id,
version: InstallationVersion,
url: HttpServer.formatAddress(address),
pid: process.pid,
password: secret,
})
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
yield* fs.rename(temp, file)
const currentID = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
yield* currentID.pipe(
Effect.flatMap((current) =>
current === id
? Effect.void
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
),
Effect.repeat(Schedule.spaced("10 seconds")),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() =>
currentID.pipe(
Effect.flatMap((current) => (current === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
})
function waitForStdinClose() {
return Effect.callback<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 }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
),
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
}
+70
View 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)
process.stderr.write(
`Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
)
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"
+2
View File
@@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
logout: () => import("./commands/handlers/mcp/logout"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
@@ -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
@@ -302,7 +302,10 @@ export function createPromptState(input: PromptInput): PromptState {
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
})
let history = createPromptHistory(input.history)
let history = createPromptHistory(input.history?.())
createEffect(() => {
history = createPromptHistory(input.history?.())
})
let draft: RunPrompt = { text: "", parts: [] }
let stash: RunPrompt = { text: "", parts: [] }
let area: TextareaRenderable | undefined
@@ -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>
+7
View 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
View 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[]
@@ -52,6 +54,7 @@ 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
@@ -61,16 +64,11 @@ const GLOBAL_FORM_SESSION_ID = "global"
export async function runNonInteractivePrompt(input: Input) {
const controller = new AbortController()
const events = await input.client.v2.event.subscribe({
signal: controller.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<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
@@ -101,7 +99,7 @@ export async function runNonInteractivePrompt(input: Input) {
UI.empty()
}
const replyPermission = async (request: { id: string; action: string; resources: string[] }) => {
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.dangerouslySkipPermissions) {
permissionRejected = true
UI.println(
@@ -110,7 +108,7 @@ export async function runNonInteractivePrompt(input: Input) {
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
)
}
await input.client.v2.session.permission
await input.client.permission
.reply({
sessionID: input.sessionID,
requestID: request.id,
@@ -118,24 +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.v2.session.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
}
const consume = async () => {
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) {
@@ -156,7 +160,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = toMillis(event.created)
const time = toMillis("created" in event ? event.created : undefined)
if (event.type === "session.prompt.promoted") {
if (event.data.inputID === messageID) {
@@ -367,34 +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))
@@ -402,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,
@@ -413,35 +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, forms] = await Promise.all([
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
Promise.all(
(input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
input.client.v2.session.form.list({ sessionID }).catch(() => undefined),
input.client.form.list({ sessionID }).catch(() => undefined),
),
),
])
await Promise.all([
...(permissions?.data?.data ?? []).map(replyPermission),
...(questions?.data?.data ?? []).map(rejectQuestion),
...forms.flatMap((response) => response?.data?.data ?? []).map(cancelForm),
...(permissions ?? []).map(replyPermission),
...(questions ?? []).map(rejectQuestion),
...forms.flatMap((response) => response ?? []).map(cancelForm),
])
await completed
} finally {
@@ -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
View 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,
})
}),
)
@@ -188,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 { 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,6 +405,43 @@ 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)
}
@@ -417,12 +485,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
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, savedVariant, state.variants)
? resolveVariant(ctx.variant, current, saved, state.variants)
: current && !state.variants.includes(current)
? undefined
: current
@@ -430,7 +499,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
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) })
footer.event({
type: "model",
model: formatModelLabel(state.model, state.activeVariant, state.providers),
selection: state.model,
})
}
let catalogRefresh: Promise<void> | undefined
@@ -456,24 +529,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
void catalogRefresh.catch(() => {})
}
const initialCatalog = footer
.idle()
.then(loadCatalog)
.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,
@@ -481,13 +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) => applyModelInfo(info, session.variant, true))
if (input.afterPaint) {
void firstPaint.then(() => (footer.isClosed ? undefined : input.afterPaint?.(ctx))).catch(() => {})
}
const streamTask = deps.streamTransport ?? import("./stream-v2.transport")
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)
})
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
@@ -501,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")
}
@@ -570,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()
@@ -615,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,
})
@@ -665,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)
@@ -727,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.
@@ -737,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 {
@@ -767,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(
@@ -833,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"
@@ -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,14 +15,9 @@
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists.
import type {
OpencodeClient,
SessionMessage,
SessionMessageAssistantTool,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80
@@ -31,6 +26,8 @@ const CHILD_EVENT_BUFFER_LIMIT = 64
const FAMILY_LIST_LIMIT = 100
const FALLBACK_LABEL = "Subagent"
type V2Event = EventSubscribeOutput
export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) {
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
}
@@ -165,7 +162,7 @@ type ChildState = {
}
export type SubagentTrackerInput = {
sdk: OpencodeClient
sdk: OpenCodeClient
sessionID: string
thinking: boolean
emit: () => void
@@ -390,8 +387,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const pendingPrompts = new Map(child.prompts)
const pendingTools = new Map(child.tools)
let retry = false
const task = input.sdk.v2.session
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
const task = input.sdk.message
.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" })
.then((response) => {
const buffered = hydrationEvents.get(child.sessionID) ?? []
hydrationEvents.delete(child.sessionID)
@@ -404,7 +401,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
for (const [id, prompt] of pendingPrompts) {
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
}
rebuild(child, response.data.data.toReversed())
rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[])
for (const [id, tool] of pendingTools) {
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
}
@@ -428,10 +425,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return
checked.add(sessionID)
if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
void input.sdk.v2.session
.get({ sessionID }, { throwOnError: true })
.then((response) => {
const session = response.data.data
void input.sdk.session
.get({ sessionID })
.then((session) => {
const buffered = pendingEvents.get(sessionID) ?? []
pendingEvents.delete(sessionID)
if (session.parentID !== input.sessionID) return
@@ -558,14 +554,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
})
childTool(
child,
{
structuredClone({
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created },
},
}) as SessionMessageAssistantTool,
event.data.assistantMessageID,
)
touch(child, event.created)
@@ -578,7 +574,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const failed = event.type === "session.tool.failed"
childTool(
child,
{
structuredClone({
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
@@ -605,7 +601,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
ran: current?.started,
completed: event.created,
},
},
}) as SessionMessageAssistantTool,
event.data.assistantMessageID,
)
touch(child, event.created)
@@ -697,9 +693,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
// Family index: adopt children directly from the current session list so
// historical subagents beyond the projected message window still get tabs.
const family = await input.sdk.v2.session
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
.then((response) => response.data.data)
const family = await input.sdk.session
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" })
.then((response) => response.data)
.catch(() => [])
for (const session of family) {
const child = ensureChild(session.id)
@@ -1,13 +1,13 @@
import type {
OpencodeClient,
EventSubscribeOutput,
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type {
PermissionRequest,
PermissionV2Request,
QuestionRequest,
QuestionV2Request,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantTool,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Event } from "@opencode-ai/schema/event"
import { blockerStatus, pickBlockerView } from "./session-data"
@@ -31,7 +31,7 @@ type Trace = {
}
type StreamInput = {
sdk: OpencodeClient
sdk: OpenCodeClient
directory?: string
sessionID: string
thinking: boolean
@@ -90,7 +90,9 @@ type ShellWait = {
abort: () => void
}
type RunV2Event = V2Event
type RunV2Event = EventSubscribeOutput
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = {
@@ -144,9 +146,9 @@ function permission(request: PermissionV2Request): PermissionRequest {
id: request.id,
sessionID: request.sessionID,
permission: request.action,
patterns: request.resources,
patterns: [...request.resources],
metadata: request.metadata ?? {},
always: request.save ?? [],
always: [...(request.save ?? [])],
tool: request.source?.type === "tool" ? request.source : undefined,
}
}
@@ -155,7 +157,7 @@ function question(request: QuestionV2Request): QuestionRequest {
return {
id: request.id,
sessionID: request.sessionID,
questions: request.questions,
questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
tool: request.tool,
}
}
@@ -303,14 +305,14 @@ async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnIn
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 }
}
@@ -532,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 })
const running = input.sessionID in active
write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" })
if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) {
const current = state.wait
@@ -721,14 +720,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
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.created, ran: event.created },
}
}) as SessionMessageAssistantTool
renderTool(event.data.assistantMessageID, item)
return
}
@@ -736,7 +735,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
const current = state.tools.get(event.data.callID)
const failed = event.type === "session.tool.failed"
const item: SessionMessageAssistantTool = {
const item = structuredClone({
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
@@ -759,7 +758,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
result: event.data.result,
},
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
}
}) as SessionMessageAssistantTool
renderTool(event.data.assistantMessageID, item)
return
}
@@ -838,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")
@@ -910,9 +904,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.trace?.write("send.shell", { sessionID: input.sessionID, id: eventID, command: next.prompt.text })
write([], { phase: "running", status: "running shell" })
try {
await input.sdk.v2.session.shell(
await input.sdk.session.shell(
{ sessionID: input.sessionID, id: eventID, command: next.prompt.text },
{ throwOnError: true, signal: abort.signal },
{ signal: abort.signal },
)
await Promise.race([output, wait(SHELL_OUTPUT_GRACE_MS, abort.signal)])
} catch (error) {
@@ -950,7 +944,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.wait = active
const interrupt = () => {
active.interrupted = true
void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
void input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
@@ -981,9 +975,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.skill(
input.sdk.session.skill(
{ sessionID: input.sessionID, id: messageID, skill: command.name },
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
),
})
return
@@ -1001,7 +995,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.command(
input.sdk.session.command(
{
sessionID: input.sessionID,
id: messageID,
@@ -1013,24 +1007,24 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
agents: agents.length ? agents : undefined,
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
{ 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))
@@ -1042,7 +1036,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.v2.session.prompt(
input.sdk.session.prompt(
{
sessionID: input.sessionID,
id: messageID,
@@ -1053,7 +1047,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
},
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
),
})
},
@@ -1067,7 +1061,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
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)
@@ -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,7 @@ 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)
}
@@ -688,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 ""
}
@@ -727,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)
}
@@ -756,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 ""
}
@@ -778,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)
}
@@ -792,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) {
@@ -828,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)
@@ -859,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 ?? ""
@@ -878,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 ?? ""
@@ -902,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"
@@ -911,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) {
@@ -921,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 {
@@ -933,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: "→",
@@ -942,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: "✱",
@@ -951,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: "✱",
@@ -969,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: "#",
@@ -978,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 {
@@ -988,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: "%",
@@ -997,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 {
@@ -1007,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
@@ -1273,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 || "",
@@ -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
@@ -111,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
@@ -282,6 +288,10 @@ export type FooterOutput = {
// transport both emit these to update footer state without reaching into
// internal signals directly.
export type FooterEvent =
| {
type: "history"
history: RunPrompt[]
}
| {
type: "catalog"
agents: RunAgent[]
@@ -312,6 +322,7 @@ export type FooterEvent =
| {
type: "model"
model: string
selection: NonNullable<RunInput["model"]>
}
| {
type: "turn.send"
@@ -341,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
View 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
View 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()
})
})
}
@@ -4,7 +4,6 @@ import { Service } from "@opencode-ai/client/effect"
import { Effect, FileSystem, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import semver from "semver"
// The CLI's service configuration file, plus the Service.Options binding that
// points the client package's service operations at this CLI: which
@@ -46,21 +45,10 @@ export const options = Effect.fnUntraced(function* () {
return {
file,
version: InstallationVersion,
canReplace: (version: string | undefined) => canReplaceVersion(version, InstallationVersion),
command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"],
}
})
export function canReplaceVersion(serverVersion: string | undefined, clientVersion: string) {
if (serverVersion === undefined) return true
// Preview versions end in `<channel>-<build>[.<attempt>]`. Convert the build
// to a numeric semver identifier so next-15000 sorts after next-9999.
const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
if (!semver.valid(server) || !semver.valid(client)) return true
return semver.lt(server, client)
}
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* env
return yield* fs.readFileString(configFile).pipe(
+16 -6
View File
@@ -9,11 +9,17 @@ import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
function command(password: string) {
type Options = {
readonly command?: ReadonlyArray<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(),
// Explicit entry wins over anything inherited, so a user-exported
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
@@ -28,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"
+1 -4
View File
@@ -12,9 +12,8 @@ import type { Args } from "@opencode-ai/tui/context/args"
export function runTui(
transport: Service.Transport,
args: Args,
discover?: () => Promise<Service.Transport | { restart: true }>,
discover?: () => Promise<Service.Transport>,
reload?: () => Promise<void>,
restart?: () => void,
) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
@@ -33,14 +32,12 @@ export function runTui(
discover: discover
? async () => {
const next = await discover()
if ("restart" in next) return next
return {
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
}
}
: undefined,
restart,
reload,
args,
config,
@@ -1,6 +0,0 @@
const stateFile = process.argv[2]
const exit = Number(process.argv[3])
const count = (await Bun.file(stateFile).exists()) ? Number(await Bun.file(stateFile).text()) : 0
await Bun.write(stateFile, String(count + 1))
process.exit(exit || (count === 0 ? 75 : 0))
@@ -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
}),
),
-43
View File
@@ -1,43 +0,0 @@
import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const launcher = path.join(import.meta.dir, "../bin/opencode2.cjs")
const fixture = path.join(import.meta.dir, "fixture/restart-child.ts")
test("restarts the installed binary once with the original arguments", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-launcher-"))
const state = path.join(root, "count")
try {
const child = Bun.spawn([process.execPath, launcher, fixture, state, "0"], {
env: { ...process.env, OPENCODE_BIN_PATH: process.execPath },
stdout: "pipe",
stderr: "pipe",
})
expect(await child.exited).toBe(0)
expect(await Bun.file(state).text()).toBe("2")
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("bounds automatic restarts", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-launcher-"))
const state = path.join(root, "count")
try {
const child = Bun.spawn([process.execPath, launcher, fixture, state, "75"], {
env: { ...process.env, OPENCODE_BIN_PATH: process.execPath },
stdout: "pipe",
stderr: "pipe",
})
expect(await child.exited).toBe(75)
expect(await Bun.file(state).text()).toBe("2")
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
+142
View 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: "Failed to resolve server directory" },
})
} 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")
}
})
})
-12
View File
@@ -7,18 +7,6 @@ import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"
test("only replaces older semantic versions", () => {
expect(ServiceConfig.canReplaceVersion("1.0.0", "2.0.0")).toBe(true)
expect(ServiceConfig.canReplaceVersion("2.0.0", "1.0.0")).toBe(false)
expect(ServiceConfig.canReplaceVersion("1.0.0", "1.0.0")).toBe(false)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-9999", "0.0.0-next-15000")).toBe(true)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000", "0.0.0-next-9999")).toBe(false)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.1", "0.0.0-next-15000.2")).toBe(true)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.10", "0.0.0-next-15000.9")).toBe(false)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.10", "0.0.0-next-15000.10")).toBe(false)
expect(ServiceConfig.canReplaceVersion(undefined, "1.0.0")).toBe(true)
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
+3 -2
View File
@@ -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")
+23 -15
View File
@@ -66,7 +66,7 @@ export type Endpoint4_1Input = {
export type Endpoint4_1Output = EffectValue<ReturnType<RawClient["server.session"]["session.create"]>>["data"]
export type SessionCreateOperation<E = never> = (input?: Endpoint4_1Input) => Effect.Effect<Endpoint4_1Output, E>
export type Endpoint4_2Output = EffectValue<ReturnType<RawClient["server.session"]["session.active"]>>
export type Endpoint4_2Output = EffectValue<ReturnType<RawClient["server.session"]["session.active"]>>["data"]
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint4_2Output, E>
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
@@ -450,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>
}
@@ -654,12 +658,8 @@ export interface SkillApi<E = never> {
export type Endpoint18_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>>
export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint18_0Output, E>
export type Endpoint18_1Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.changes"]>>>
export type EventChangesOperation<E = never> = () => Stream.Stream<Endpoint18_1Output, E>
export interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E>
readonly changes: EventChangesOperation<E>
}
type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
@@ -862,6 +862,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>
@@ -888,4 +895,5 @@ export interface AppApi<E = never> {
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
}
+25 -20
View File
@@ -82,7 +82,10 @@ const Endpoint4_1 = (raw: RawClient["server.session"]) => (input?: Endpoint4_1In
)
const Endpoint4_2 = (raw: RawClient["server.session"]) => () =>
raw["session.active"]({}).pipe(Effect.mapError(mapClientError))
raw["session.active"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] }
@@ -544,25 +547,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]
@@ -792,15 +799,7 @@ const Endpoint18_0 = (raw: RawClient["server.event"]) => () =>
),
)
const Endpoint18_1 = (raw: RawClient["server.event"]) => () =>
Stream.unwrap(
raw["event.changes"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
),
)
const adaptGroup18 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint18_0(raw), changes: Endpoint18_1(raw) })
const adaptGroup18 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint18_0(raw) })
type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
@@ -1043,6 +1042,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"]),
@@ -1069,6 +1073,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 }) =>
+45 -43
View File
@@ -1,4 +1,4 @@
import { Data, Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
@@ -23,9 +23,6 @@ export type Options = {
// When set, discovery only returns a server reporting this exact version,
// and start() replaces a healthy server whose version differs.
readonly version?: string
// Decides whether start() may terminate a healthy version-mismatched server.
// Defaults to true for callers that do not need directional version handling.
readonly canReplace?: (version: string | undefined) => boolean
// Argv used to spawn the service. Defaults to ["opencode", "serve",
// "--service"] resolved from PATH.
readonly command?: ReadonlyArray<string>
@@ -34,11 +31,14 @@ export type Options = {
// Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to start() is the caller's policy.
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
return (yield* discoverLocal(options))?.transport
})
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
const info = yield* read(options.file)
if (info === undefined) return undefined
if (options.version !== undefined && info.version !== options.version) return undefined
const found = yield* probe(info)
return found?.transport
return yield* probe(info, options.version)
})
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
@@ -47,26 +47,33 @@ 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) {
const error = replacementError(mismatched.info, options)
if (error) return yield* Effect.fail(error)
yield* kill(mismatched.info, 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")),
)
})
@@ -74,12 +81,8 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
export const stop = Effect.fn("service.stop")(function* (options: Options = {}) {
const fs = yield* FileSystem.FileSystem
const existing = yield* find(options)
if (existing !== undefined) {
const error = replacementError(existing.info, options)
if (error) return yield* Effect.fail(error)
yield* kill(existing.info, options)
}
return yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
if (existing !== undefined) yield* kill(existing.info, options)
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
})
function fallback() {
@@ -100,23 +103,11 @@ export const Info = Schema.Struct({
})
export type Info = typeof Info.Type
export class VersionMismatchError extends Data.TaggedError("ServiceVersionMismatchError")<{
readonly clientVersion: string | undefined
readonly serverVersion: string | undefined
readonly message: string
}> {}
function replacementError(info: Info, options: Options): VersionMismatchError | undefined {
if (options.version === undefined || info.version === options.version || options.canReplace?.(info.version) !== false)
return undefined
return new VersionMismatchError({
clientVersion: options.version,
serverVersion: info.version,
message: `Client version ${options.version} cannot replace server version ${info.version ?? "unknown"}`,
})
}
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 info; callers treat both
// the same (the registering server self-evicts, clients rediscover).
@@ -132,18 +123,29 @@ type LocalService = {
readonly transport: Transport
}
const probe = Effect.fnUntraced(function* (info: Info) {
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
const headers = info.password === undefined ? undefined : auth(info.password)
const healthy = yield* Effect.tryPromise(() =>
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", info.url), {
headers,
signal: AbortSignal.timeout(2_000),
}),
).pipe(
Effect.map((response) => response.ok),
Effect.orElseSucceed(() => false),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || !response.ok) return undefined
const 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 undefined
return { info, transport: { url: info.url, headers } } satisfies LocalService
})
@@ -152,7 +154,7 @@ const probe = Effect.fnUntraced(function* (info: Info) {
const find = Effect.fnUntraced(function* (options: Options) {
const info = yield* read(options.file)
if (info === undefined) return undefined
return yield* probe(info)
return yield* probe(info, undefined, true)
})
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
@@ -89,6 +89,7 @@ import type {
CredentialUpdateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectDirectoriesInput,
@@ -132,7 +133,6 @@ import type {
SkillListInput,
SkillListOutput,
EventSubscribeOutput,
EventChangesOutput,
PtyListInput,
PtyListOutput,
PtyCreateInput,
@@ -173,6 +173,7 @@ import type {
VcsStatusOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -400,7 +401,7 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
active: (requestOptions?: RequestOptions) =>
request<SessionActiveOutput>(
request<{ readonly data: SessionActiveOutput }>(
{
method: "GET",
path: `/api/session/active`,
@@ -409,7 +410,7 @@ export function make(options: ClientOptions) {
empty: false,
},
requestOptions,
),
).then((value) => value.data),
get: (input: SessionGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionGetOutput }>(
{
@@ -644,7 +645,7 @@ export function make(options: ClientOptions) {
sse<SessionLogOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/log`,
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
query: { after: input["after"], follow: input["follow"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
@@ -898,6 +899,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>(
{
@@ -1176,11 +1182,6 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
changes: (requestOptions?: RequestOptions): AsyncIterable<EventChangesOutput> =>
sse<EventChangesOutput>(
{ method: "GET", path: `/api/event/changes`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
},
pty: {
list: (input?: PtyListInput, requestOptions?: RequestOptions) =>
@@ -1448,6 +1449,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,
),
},
}
}
+23 -11
View File
@@ -157,7 +157,7 @@ export type ProjectCopyError = {
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
export type HealthGetOutput = { readonly healthy: true }
export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
export type LocationGetInput = {
readonly location?: {
@@ -354,7 +354,6 @@ export type SessionListOutput = {
}>
}
}>
readonly watermarks: { readonly [x: string]: number }
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
@@ -419,10 +418,7 @@ export type SessionCreateOutput = {
}
}["data"]
export type SessionActiveOutput = {
readonly data: { readonly [x: string]: { readonly type: "running" } }
readonly watermarks: { readonly [x: string]: number }
}
export type SessionActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"]
export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -1987,7 +1983,6 @@ export type MessageListOutput = {
readonly time: { readonly created: number }
}
>
readonly watermark?: number
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
@@ -2458,6 +2453,17 @@ export type CredentialRemoveInput = {
export type CredentialRemoveOutput = void
export type ProjectListOutput = ReadonlyArray<{
readonly id: string
readonly worktree: string
readonly vcs?: "git" | "hg"
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
readonly time: { readonly created: number; readonly updated: number; readonly initialized?: number }
readonly sandboxes: ReadonlyArray<string>
}>
export type ProjectCurrentInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -4974,6 +4980,14 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "plugin.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly created: number
@@ -5527,10 +5541,6 @@ export type EventSubscribeOutput =
readonly data: {}
}
export type EventChangesOutput =
| { readonly type: "log.hint"; readonly aggregateID: string; readonly seq: number }
| { readonly type: "log.sweep_required" }
export type PtyListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -6003,3 +6013,5 @@ export type VcsDiffOutput = {
readonly status?: "added" | "deleted" | "modified"
}>
}
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
+3 -4
View File
@@ -99,7 +99,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }),
Response.json({ data: { ses_test: { type: "running" } } }),
),
)
}
@@ -112,7 +112,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ data: [session.data], watermarks: { ses_test: 3 }, cursor: { next: "next" } }),
Response.json({ data: [session.data], cursor: { next: "next" } }),
),
)
})
@@ -148,8 +148,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(result.active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
expect(result.page.watermarks).toEqual({ ses_test: 3 })
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
+6 -4
View File
@@ -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",
@@ -45,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["current", "directories"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
})
test("file.read returns binary content from the public HTTP contract", async () => {
@@ -196,7 +198,7 @@ test("session methods use the public HTTP contract", async () => {
if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active"))
return Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } })
@@ -225,7 +227,7 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
expect(active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(context).toEqual([])
@@ -241,7 +243,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/session/ses_test/log?after=0"],
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
])
-49
View File
@@ -1,49 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Service } from "../src/effect/index"
const cleanup: Array<() => void | Promise<void>> = []
afterEach(async () => {
await Promise.all(cleanup.splice(0).map(async (run) => await run()))
})
test("does not start or stop a healthy service rejected by the version policy", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-client-service-"))
const child = Bun.spawn([process.execPath, "-e", "await new Promise(() => {})"])
const server = Bun.serve({ port: 0, fetch: () => Response.json({ healthy: true }) })
const file = path.join(root, "service.json")
cleanup.push(
() => child.kill(),
() => server.stop(true),
() => fs.rm(root, { recursive: true, force: true }),
)
await Bun.write(file, JSON.stringify({ id: "newer", version: "2.0.0", url: server.url.toString(), pid: child.pid }))
const options = {
file,
version: "1.0.0",
canReplace: () => false,
command: [process.execPath, "-e", "throw new Error('should not spawn')"],
}
const startError = await Service.start(options).pipe(
Effect.flip,
Effect.provide(NodeFileSystem.layer),
Effect.runPromise,
)
const stopError = await Service.stop(options).pipe(
Effect.flip,
Effect.provide(NodeFileSystem.layer),
Effect.runPromise,
)
expect(startError).toBeInstanceOf(Service.VersionMismatchError)
expect(startError).toMatchObject({ clientVersion: "1.0.0", serverVersion: "2.0.0" })
expect(stopError).toBeInstanceOf(Service.VersionMismatchError)
expect(process.kill(child.pid, 0)).toBe(true)
expect(await Bun.file(file).exists()).toBe(true)
})
+21 -13
View File
@@ -179,14 +179,14 @@ Supported bearer, basic, header, and query authentication follows OpenAPI `secur
## 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 agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. 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 against the shared budget, and a namespace whose next signature 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:
The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime:
```ts
const runtime = CodeMode.make({
tools,
discovery: { maxInlineCatalogTokens: 6_000 },
discovery: { catalogBudget: 6_000 },
})
```
@@ -199,30 +199,37 @@ const matches = await tools.$codemode.search({
query: "order status",
namespace: "orders", // optional: scope to one top-level namespace
limit: 10,
offset: 0,
})
```
`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).
`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), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit.
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
const request = { query: "order status", namespace: "orders", limit: 10 }
const page = await tools.$codemode.search(request)
const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined
```
Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the 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`).
```ts
tools.github.list_issues(input: {
/** Repository owner */
owner: string
owner: string,
/** Cursor from the previous response's pageInfo */
after?: string
after?: string,
/**
* Results per page
* @default 30
*/
perPage?: number
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.
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; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise<unknown>` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, 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.
@@ -234,14 +241,15 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
- `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`.
- 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, including `$codemode`, and `Object.keys(tools.ns)` lists 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.
- 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). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. 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.
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
- 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.
- `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`, `URL`, `URLSearchParams`, `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 `{}`.
Inside a program, standard-library 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 they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams 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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
import type { SafeObject } from "../tool-runtime.js"
import type { SandboxURL } from "../values.js"
export type SourcePosition = {
line: number
column: number
}
export type SourceLocation = {
start: SourcePosition
end: SourcePosition
}
export type AstNode = {
type: string
loc?: SourceLocation
[key: string]: unknown
}
export type ProgramNode = AstNode & {
type: "Program"
body: Array<AstNode>
}
export type Binding = {
mutable: boolean
value: unknown
initialized?: boolean
}
export type StatementResult =
| { kind: "none" }
| { kind: "value"; value: unknown }
| { kind: "return"; value: unknown }
| { kind: "break" }
| { kind: "continue" }
export type MemberReference = {
target: SafeObject | Array<unknown> | SandboxURL
key: string | number
}
export class CodeModeFunction {
constructor(
readonly parameters: ReadonlyArray<AstNode>,
readonly body: AstNode,
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
) {}
}
export class IntrinsicReference {
constructor(
readonly receiver: unknown,
readonly name: string,
) {}
}
export class ComputedValue {
constructor(readonly value: unknown) {}
}
export class PromiseNamespace {}
export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject"
export class PromiseMethodReference {
constructor(readonly name: PromiseMethodName) {}
}
export type GlobalNamespaceName =
| "Object"
| "Math"
| "JSON"
| "Array"
| "console"
| "Date"
| "RegExp"
| "Map"
| "Set"
| "URL"
| "URLSearchParams"
export class GlobalNamespace {
constructor(readonly name: GlobalNamespaceName) {}
}
export class GlobalMethodReference {
constructor(
readonly namespace: GlobalNamespaceName | "Number" | "String",
readonly name: string,
) {}
}
export class CoercionFunction {
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
}
export class UriFunction {
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
}
export class ProgramThrow {
constructor(readonly value: unknown) {}
}
export class ErrorConstructorReference {
constructor(readonly name: string) {}
}
export type DiagnosticKind =
| "ParseError"
| "UnsupportedSyntax"
| "UnknownTool"
| "InvalidToolInput"
| "InvalidToolOutput"
| "InvalidDataValue"
| "ToolCallLimitExceeded"
| "TimeoutExceeded"
| "ToolFailure"
| "ExecutionFailure"
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
errorName: string = "Error"
constructor(
message: string,
node?: AstNode,
readonly kind: DiagnosticKind = "ExecutionFailure",
readonly suggestions?: ReadonlyArray<string>,
) {
super(message)
this.name = "InterpreterRuntimeError"
if (node) this.node = node
}
as(errorName: string): this {
this.errorName = errorName
return this
}
}
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`,
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
export const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
export const asNode = (value: unknown, context: string): AstNode => {
if (!isRecord(value) || typeof value.type !== "string") {
throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`)
}
return value as AstNode
}
export const getArray = (node: AstNode, key: string): Array<unknown> => {
const value = node[key]
if (!Array.isArray(value)) throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node)
return value
}
export const getString = (node: AstNode, key: string): string => {
const value = node[key]
if (typeof value !== "string") throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node)
return value
}
export const getBoolean = (node: AstNode, key: string): boolean => {
const value = node[key]
if (typeof value !== "boolean") throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node)
return value
}
export const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => {
const value = node[key]
if (value === undefined || value === null) return undefined
return asNode(value, key)
}
export const getNode = (node: AstNode, key: string): AstNode => asNode(node[key], key)
export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
})
export const formatLocation = (node?: AstNode): string => {
if (!node?.loc) return ""
const location = sourceLocation(node)
return ` (line ${location.line}, col ${location.column})`
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
export const arrayMethods = new Set([
"map",
"filter",
"find",
"findIndex",
"findLast",
"findLastIndex",
"some",
"every",
"includes",
"join",
"reduce",
"reduceRight",
"flatMap",
"forEach",
"sort",
"toSorted",
"slice",
"concat",
"indexOf",
"lastIndexOf",
"at",
"flat",
"reverse",
"toReversed",
"with",
"push",
"pop",
"shift",
"unshift",
"splice",
"fill",
"copyWithin",
"keys",
"values",
"entries",
])
export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
if (Array.isArray(value)) return value
if (typeof value === "string") return Array.from(value)
if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof SandboxSet) return Array.from(value.set.values())
if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
return undefined
}
import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"
+4
View File
@@ -0,0 +1,4 @@
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
/** Console formatting recursion ceiling; deeper values render as "...". */
export const MAX_CONSOLE_DEPTH = 32
+94
View File
@@ -0,0 +1,94 @@
export const dateMethods = new Set([
"getTime",
"valueOf",
"toISOString",
"toJSON",
"toString",
"getFullYear",
"getMonth",
"getDate",
"getDay",
"getHours",
"getMinutes",
"getSeconds",
"getMilliseconds",
"getUTCFullYear",
"getUTCMonth",
"getUTCDate",
"getUTCDay",
"getUTCHours",
"getUTCMinutes",
"getUTCSeconds",
"getUTCMilliseconds",
"getTimezoneOffset",
])
export const dateStatics = new Set(["now", "parse", "UTC"])
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
switch (name) {
case "now":
return Date.now()
case "parse":
return Date.parse(coerceToString(args[0]))
case "UTC":
return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))
default:
throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node)
}
}
export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
const hosted = new Date(value.time)
switch (name) {
case "getTime":
case "valueOf":
return value.time
case "toISOString":
if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node)
return hosted.toISOString()
case "toJSON":
return Number.isFinite(value.time) ? hosted.toISOString() : null
case "toString":
return coerceToString(value)
case "getFullYear":
return hosted.getFullYear()
case "getMonth":
return hosted.getMonth()
case "getDate":
return hosted.getDate()
case "getDay":
return hosted.getDay()
case "getHours":
return hosted.getHours()
case "getMinutes":
return hosted.getMinutes()
case "getSeconds":
return hosted.getSeconds()
case "getMilliseconds":
return hosted.getMilliseconds()
case "getUTCFullYear":
return hosted.getUTCFullYear()
case "getUTCMonth":
return hosted.getUTCMonth()
case "getUTCDate":
return hosted.getUTCDate()
case "getUTCDay":
return hosted.getUTCDay()
case "getUTCHours":
return hosted.getUTCHours()
case "getUTCMinutes":
return hosted.getUTCMinutes()
case "getUTCSeconds":
return hosted.getUTCSeconds()
case "getUTCMilliseconds":
return hosted.getUTCMilliseconds()
case "getTimezoneOffset":
return hosted.getTimezoneOffset()
default:
throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { SandboxDate } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
+42
View File
@@ -0,0 +1,42 @@
import {
type AstNode,
CodeModeFunction,
InterpreterRuntimeError,
supportedSyntaxMessage,
} from "../interpreter/model.js"
import { copyIn, copyOut } from "../tool-runtime.js"
export const jsonStatics = new Set(["stringify", "parse"])
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
switch (name) {
case "stringify": {
const replacer = args[1]
if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
throw new InterpreterRuntimeError(
"JSON.stringify replacers are not supported in CodeMode.",
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
}
const space = args[2]
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
}
case "parse": {
const text = args[0]
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
try {
return copyIn(JSON.parse(text), "JSON.parse result")
} catch (error) {
throw new InterpreterRuntimeError(
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
node,
).as("SyntaxError")
}
}
}
throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
}
+65
View File
@@ -0,0 +1,65 @@
export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"])
export const mathMethods = new Set([
"max",
"min",
"abs",
"floor",
"ceil",
"round",
"trunc",
"sign",
"sqrt",
"cbrt",
"pow",
"hypot",
"log",
"log2",
"log10",
"exp",
])
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
const nums = args.map((arg) => {
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
return arg
})
const [a = Number.NaN, b = Number.NaN] = nums
switch (name) {
case "max":
return Math.max(...nums)
case "min":
return Math.min(...nums)
case "abs":
return Math.abs(a)
case "floor":
return Math.floor(a)
case "ceil":
return Math.ceil(a)
case "round":
return Math.round(a)
case "trunc":
return Math.trunc(a)
case "sign":
return Math.sign(a)
case "sqrt":
return Math.sqrt(a)
case "cbrt":
return Math.cbrt(a)
case "pow":
return Math.pow(a, b)
case "hypot":
return Math.hypot(...nums)
case "log":
return Math.log(a)
case "log2":
return Math.log2(a)
case "log10":
return Math.log10(a)
case "exp":
return Math.exp(a)
}
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+66
View File
@@ -0,0 +1,66 @@
export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"])
export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"])
export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"])
export const invokeNumberMethod = (value: number, name: string, args: Array<unknown>, node: AstNode): unknown => {
const optNum = (index: number): number | undefined => {
const arg = args[index]
if (arg === undefined) return undefined
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
return arg
}
let result: unknown
switch (name) {
case "toFixed":
result = value.toFixed(optNum(0))
break
case "toExponential":
result = value.toExponential(optNum(0))
break
case "toPrecision": {
const digits = optNum(0)
result = digits === undefined ? value.toString() : value.toPrecision(digits)
break
}
case "toString": {
const radix = optNum(0)
if (radix !== undefined && (radix < 2 || radix > 36)) {
throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node)
}
result = value.toString(radix)
break
}
default:
throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
}
return boundedData(result, `Number.${name} result`)
}
export const invokeNumberStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
const value = args[0]
switch (name) {
case "isInteger":
return Number.isInteger(value)
case "isFinite":
return Number.isFinite(value)
case "isNaN":
return Number.isNaN(value)
case "isSafeInteger":
return Number.isSafeInteger(value)
case "parseInt": {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
}
return parseInt(coerceToString(value), radix)
}
case "parseFloat":
return parseFloat(coerceToString(value))
default:
throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node)
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { boundedData, coerceToString } from "./value.js"
+77
View File
@@ -0,0 +1,77 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isSandboxValue, SandboxMap, SandboxURLSearchParams } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
const requireObject = (): Record<string, unknown> => {
const value = boundedData(args[0], `Object.${name} input`)
if (isSandboxValue(value)) return {}
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node)
}
return value as Record<string, unknown>
}
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
out[key] = item
}
switch (name) {
case "keys": {
const value = boundedData(args[0], "Object.keys input")
if (isSandboxValue(value)) return []
if (Array.isArray(value)) return Object.keys(value)
if (value === null || typeof value !== "object") {
throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node)
}
return Object.keys(value)
}
case "values":
return Object.values(requireObject())
case "entries":
return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1]))
case "assign": {
const out: Record<string, unknown> = Object.create(null)
for (const source of args) {
if (source === null || source === undefined) continue
const value = boundedData(source, "Object.assign input")
if (isSandboxValue(value)) continue
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
}
return out
}
case "fromEntries": {
if (args[0] instanceof SandboxMap) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item)
return out
}
if (args[0] instanceof SandboxURLSearchParams) {
const out: Record<string, unknown> = Object.create(null)
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
return out
}
const pairs = boundedData(args[0], "Object.fromEntries input")
if (!Array.isArray(pairs)) {
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
}
const out: Record<string, unknown> = Object.create(null)
for (const pair of pairs) {
if (!Array.isArray(pair)) {
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node)
}
guardedSet(out, String(pair[0]), pair[1])
}
return out
}
}
throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
}
+6
View File
@@ -0,0 +1,6 @@
import type { PromiseMethodName } from "../interpreter/model.js"
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "resolve", "reject"])
/** Maximum number of eagerly forked tool calls that may run concurrently. */
export const TOOL_CALL_CONCURRENCY = 8
+74
View File
@@ -0,0 +1,74 @@
export const regexpMethods = new Set(["test", "exec", "toString"])
export const regexpProperties = new Set([
"source",
"flags",
"lastIndex",
"global",
"ignoreCase",
"multiline",
"sticky",
"unicode",
"dotAll",
])
export const regexFailureReason = (error: unknown): string =>
(error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "")
export const escapeRegexHint =
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
if (arg instanceof SandboxRegExp) return arg.regex
if (typeof arg === "string") {
try {
return new RegExp(arg, extraFlags)
} catch (error) {
throw new InterpreterRuntimeError(
`String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
node,
).as("SyntaxError")
}
}
throw new InterpreterRuntimeError(
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
node,
)
}
export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
const result: Array<unknown> = Array.from(match, (group) => group)
if (match.index !== undefined) (result as Record<string, unknown> & Array<unknown>).index = match.index
if (match.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(match.groups)) {
if (!isBlockedMember(key)) groups[key] = group
}
;(result as Record<string, unknown> & Array<unknown>).groups = groups
}
return result
}
export const invokeRegExpMethod = (
value: SandboxRegExp,
name: string,
args: Array<unknown>,
node: AstNode,
): unknown => {
switch (name) {
case "test":
return value.regex.test(coerceToString(args[0]))
case "exec": {
const matched = value.regex.exec(coerceToString(args[0]))
return matched === null ? null : matchToValue(matched)
}
case "toString":
return coerceToString(value)
default:
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { SandboxRegExp } from "../values.js"
import { coerceToString } from "./value.js"
+52
View File
@@ -0,0 +1,52 @@
export const stringMethods = new Set([
"toLowerCase",
"toUpperCase",
"trim",
"trimStart",
"trimEnd",
"trimLeft",
"trimRight",
"split",
"slice",
"substring",
"substr",
"includes",
"startsWith",
"endsWith",
"indexOf",
"lastIndexOf",
"replace",
"replaceAll",
"repeat",
"padStart",
"padEnd",
"charAt",
"charCodeAt",
"codePointAt",
"at",
"concat",
"toString",
"match",
"matchAll",
"search",
"localeCompare",
"normalize",
])
export const stringStatics = new Set(["fromCharCode", "fromCodePoint"])
export const invokeStringStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
const codes = args.map((arg) => {
if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
return arg
})
switch (name) {
case "fromCharCode":
return String.fromCharCode(...codes)
case "fromCodePoint":
return String.fromCodePoint(...codes)
default:
throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node)
}
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+90
View File
@@ -0,0 +1,90 @@
export const urlProperties = new Set([
"href",
"origin",
"protocol",
"username",
"password",
"host",
"hostname",
"port",
"pathname",
"search",
"hash",
])
export const urlWritableProperties = new Set([
"href",
"protocol",
"username",
"password",
"host",
"hostname",
"port",
"pathname",
"search",
"hash",
])
export const urlMethods = new Set(["toString", "toJSON"])
export const urlStatics = new Set(["canParse", "parse"])
export const urlSearchParamsMethods = new Set([
"append",
"delete",
"get",
"getAll",
"has",
"set",
"sort",
"forEach",
"keys",
"values",
"entries",
"toString",
])
export const uriArgument = (value: unknown, label: string): string => coerceToString(boundedData(value, label))
export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node: AstNode): string => {
const value = uriArgument(args[0], `${ref.name} input`)
try {
switch (ref.name) {
case "encodeURI":
return encodeURI(value)
case "encodeURIComponent":
return encodeURIComponent(value)
case "decodeURI":
return decodeURI(value)
case "decodeURIComponent":
return decodeURIComponent(value)
}
} catch (error) {
throw new InterpreterRuntimeError(
`${ref.name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`,
node,
).as("URIError")
}
}
export const urlArgument = (value: unknown, label: string): string =>
value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError")
const input = urlArgument(args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
try {
const url = new URL(input, base)
return name === "canParse" ? true : new SandboxURL(url)
} catch {
return name === "canParse" ? false : null
}
}
export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
}
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
import { SandboxURL } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
+90
View File
@@ -0,0 +1,90 @@
export const errorConstructors = new Set([
"Error",
"TypeError",
"RangeError",
"SyntaxError",
"ReferenceError",
"EvalError",
"URIError",
])
export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"])
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
const ErrorBrand: unique symbol = Symbol("codemode.error")
export const createErrorValue = (name: string, message: string): SafeObject => {
const value = Object.assign(Object.create(null) as SafeObject, { name, message })
Object.defineProperty(value, ErrorBrand, { value: name })
return value
}
export const errorBrandName = (value: unknown): string | undefined =>
value !== null && typeof value === "object"
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
: undefined
export const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true)
export const coerceToString = (value: unknown): string => {
if (value === null) return "null"
if (value === undefined) return "undefined"
if (value instanceof SandboxDate)
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof SandboxMap) return "[object Map]"
if (value instanceof SandboxSet) return "[object Set]"
if (value instanceof SandboxURL) return value.url.href
if (value instanceof SandboxURLSearchParams) return value.params.toString()
if (typeof value === "object") {
return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
: "[object Object]"
}
return String(value)
}
export const coerceToNumber = (value: unknown): number => {
if (value instanceof SandboxDate) return value.time
if (isSandboxValue(value)) return Number.NaN
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
}
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
const raw = args[0]
if (isSandboxValue(raw)) {
if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw)
if (ref.name === "String") return coerceToString(raw)
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
return parseFloat(coerceToString(raw))
}
const value = boundedData(args[0], `${ref.name} input`)
if (ref.name === "Number") return coerceToNumber(value)
if (ref.name === "Boolean") return Boolean(value)
if (ref.name === "parseInt") {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
}
return parseInt(coerceToString(value), radix)
}
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
return coerceToString(value)
}
import {
type AstNode,
CoercionFunction,
InterpreterRuntimeError,
} from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js"
import {
isSandboxValue,
SandboxDate,
SandboxMap,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
} from "../values.js"
+164 -189
View File
@@ -1,4 +1,4 @@
import { Cause, Effect } from "effect"
import { Cause, Effect, Schema } from "effect"
import { ToolError, toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
@@ -9,7 +9,15 @@ import {
outputTypeScript,
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
import {
SandboxDate,
SandboxMap,
SandboxPromise,
SandboxRegExp,
SandboxSet,
SandboxURL,
SandboxURLSearchParams,
} from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
@@ -75,10 +83,26 @@ export type ToolDescription = {
export type SafeObject = Record<string, unknown>
const reservedNamespace = "$codemode"
const defaultMaxInlineCatalogTokens = 2_000
const defaultCatalogBudget = 2_000
const defaultSearchLimit = 10
const searchSignature =
"tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
const SearchInput = Schema.Struct({
query: Schema.optionalKey(Schema.String),
namespace: Schema.optionalKey(Schema.String),
limit: Schema.optionalKey(PositiveInt),
offset: Schema.optionalKey(NonNegativeInt),
})
const SearchItem = Schema.Struct({
path: Schema.String,
description: Schema.String,
signature: Schema.String,
})
const SearchOutput = Schema.Struct({
items: Schema.Array(SearchItem),
remaining: NonNegativeInt,
next: Schema.NullOr(Schema.Struct({ offset: NonNegativeInt })),
})
const toolExpression = (path: string) =>
"tools" +
path
@@ -136,9 +160,9 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has
* Two modes share the walk:
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
* exactly as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}.
* exactly as JSON.stringify would: Date/URL -> strings, the remaining value types -> {}.
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
* codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves,
* codemode.ts): standard-library value instances pass through untouched (treated as leaves,
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
*
@@ -192,7 +216,9 @@ const copyBounded = (
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
) {
return value
}
@@ -212,24 +238,30 @@ const copyBounded = (
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
if (value instanceof URL) return new SandboxURL(new URL(value.href))
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
}
// Sandbox value types (and their host counterparts, which a host tool may legitimately
// return) serialize exactly as JSON.stringify would at the data boundary: a Date is its
// toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}.
// return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
// toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
if (value instanceof SandboxDate) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (value instanceof SandboxURL) return value.url.href
if (value instanceof URL) return value.href
if (
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet ||
value instanceof SandboxURLSearchParams ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set
value instanceof Set ||
value instanceof URLSearchParams
) {
return Object.create(null) as SafeObject
}
@@ -294,15 +326,17 @@ const definitions = <R>(
return entries
}
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
})
const visibleDefinitions = <R>(tools: HostTools<R>) =>
definitions(tools).map(({ path, definition }) => ({
path,
definition,
description: {
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
},
description: describeDefinition(path, definition),
}))
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
@@ -316,11 +350,6 @@ export type DiscoveryPlan = {
export type SearchEntry = {
readonly description: ToolDescription
/**
* JSDoc-annotated multiline signature shown on search-result items; the compact
* single-line form (inline catalog lines) stays in `description.signature`.
*/
readonly signature: string
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
readonly namespace: string
/** Lowercased path + description + input property names/descriptions, for substring matching. */
@@ -354,8 +383,75 @@ const termForms = (term: string): Array<string> => {
return forms
}
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
_tag: "CodeModeTool",
description: "Search available Code Mode tools",
input: SearchInput,
output: SearchOutput,
run: (input) =>
Effect.sync(() => {
const request = input as typeof SearchInput.Type
const query = request.query ?? ""
const offset = request.offset ?? 0
const scoped =
request.namespace === undefined
? searchIndex
: searchIndex.filter((entry) => entry.namespace === request.namespace)
// A query that names one tool path exactly (canonical path or rendered JavaScript
// expression) is a lookup, not a search: return that tool alone.
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact =
pathQuery === ""
? undefined
: scoped.find(
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const terms = tokenize(query).map(termForms)
// Additive field-weighted scoring, summed across terms: exact path or path segment
// (20) > path substring (8) > description substring (4) > any searchable text,
// including input parameter names and descriptions (2).
const ranked =
exact !== undefined
? [exact]
: scoped
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
)
.map(({ entry }) => entry)
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
...description,
path: toolExpression(description.path),
}))
const remaining = Math.max(0, ranked.length - offset - items.length)
return {
items,
remaining,
next: remaining > 0 ? { offset: offset + items.length } : null,
}
}),
})
const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([]))
const catalogLine = (tool: ToolDescription) => {
// Inline catalog lines use only a compact first line; full text stays in search results.
// Keep the tool description concise; the full schema documentation remains in the signature.
const line = tool.description.split("\n", 1)[0]!.trim()
const description = line.length > 120 ? line.slice(0, 119) + "..." : line
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
@@ -363,7 +459,6 @@ const catalogLine = (tool: ToolDescription) => {
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
namespace: path.split(".", 1)[0]!,
searchText: [
path,
@@ -388,7 +483,7 @@ export const assertValidTools = <R>(tools: HostTools<R>): void => {
/**
* Budgeted catalog: every namespace is always listed with its tool count; full call
* signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens,
* signatures are inlined against the `catalogBudget` (estimated tokens,
* chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
* a namespace whose next line does not fit is done while the others keep going - so every
@@ -397,12 +492,9 @@ export const assertValidTools = <R>(tools: HostTools<R>): void => {
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
* tool count even at budget 0.
*/
export const discoveryPlan = <R>(
tools: HostTools<R>,
maxInlineCatalogTokens = defaultMaxInlineCatalogTokens,
): DiscoveryPlan => {
if (!Number.isSafeInteger(maxInlineCatalogTokens) || maxInlineCatalogTokens < 0) {
throw new RangeError("discovery.maxInlineCatalogTokens must be a non-negative safe integer")
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
}
const visible = visibleDefinitions(tools)
const described = visible.map(({ description }) => description)
@@ -437,7 +529,7 @@ export const discoveryPlan = <R>(
for (const selection of active) {
const tool = selection.queue[0]!
const cost = estimateTokens(catalogLine(tool))
if (used + cost > maxInlineCatalogTokens) continue
if (used + cost > catalogBudget) continue
selection.queue.shift()
selection.picked.add(tool)
used += cost
@@ -458,13 +550,14 @@ export const discoveryPlan = <R>(
// catalog at the bottom. Example call forms use placeholders - never a real or fabricated
// tool name - and show both dot and bracket notation so non-identifier names are not normalized.
const intro = [
"Write a CodeMode program to answer the request. Return code only.",
empty
? "Execute JavaScript in a confined runtime."
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
: complete
? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here."
: "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.",
...(empty ? [] : ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available."
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.",
...(empty
? []
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
]
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
@@ -478,16 +571,12 @@ export const discoveryPlan = <R>(
...(complete
? [
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
'2. Call it using the exact signature shown; bracket notation and quotes are part of the path.',
'3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"4. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
"2. Call it using the exact signature shown: `const result = await tools.<namespace>.<tool>(input)`; bracket notation and quotes are part of the path.",
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
]
: [
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
"2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.",
"3. Call the result's `path` as-is; bracket notation and quotes are part of the path.",
'4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
"5. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
]),
]
@@ -498,25 +587,27 @@ export const discoveryPlan = <R>(
"## Rules",
"",
complete
? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed."
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.",
? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed."
: "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.",
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete
? []
: ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
: [
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
]),
]
const syntax = [
const language = [
"",
"## Syntax",
"## Language",
"",
"Standard modern JavaScript works: functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date, RegExp, Map, Set, and Promise.all/allSettled/race/resolve/reject.",
"TypeScript type annotations are allowed and stripped before execution (decorators are not supported).",
"Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch).",
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]
const toolSection: Array<string> = [""]
@@ -544,11 +635,11 @@ export const discoveryPlan = <R>(
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
}
if (!complete) {
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`)
}
}
const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection]
const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection]
return {
catalog: described,
instructions: lines.join("\n"),
@@ -557,20 +648,13 @@ export const discoveryPlan = <R>(
}
/**
* The enumerable names at one node of the host tool tree - namespace names at the root,
* The enumerable names at one node of the callable tool tree - namespace names at the root,
* tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
* discovery idioms, mirroring how calling an unknown tool fails.
*/
const namespaceKeys = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): ReadonlyArray<string> => {
// The reserved discovery namespace is virtual (never present in the host tree); enumerate
// it explicitly so `Object.keys(tools.$codemode)` matches the callable surface.
if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"]
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): ReadonlyArray<string> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
if (
@@ -579,15 +663,9 @@ const namespaceKeys = <R>(
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool namespace '${path.join(".")}'.`,
searchEnabled
? [
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
]
: ["Object.keys(tools) lists the available namespaces."],
)
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
@@ -595,11 +673,7 @@ const namespaceKeys = <R>(
return Object.keys(value)
}
const resolve = <R>(
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): HostTool<R> | Definition<R> => {
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<R> | Definition<R> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) {
@@ -609,11 +683,9 @@ const resolve = <R>(
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool '${path.join(".")}'.`,
searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [],
)
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
"Use tools.$codemode.search({ query }) to find available described tools.",
])
}
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
}
@@ -629,7 +701,7 @@ export type ToolRuntime<R = never> = {
readonly root: ToolReference
readonly calls: Array<ToolCall>
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/** Enumerable namespace/tool names at one node of the host tool tree; see `namespaceKeys`. */
/** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
@@ -637,11 +709,14 @@ export const make = <R>(
tools: HostTools<R>,
/** Undefined means unlimited tool calls. */
maxToolCalls: number | undefined,
searchIndex: ReadonlyArray<SearchEntry>,
hooks?: ToolCallHooks<R>,
searchIndex?: ReadonlyArray<SearchEntry>,
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const searchEnabled = searchIndex !== undefined
const callableTools = {
...tools,
[reservedNamespace]: { search: makeSearchTool(searchIndex) },
}
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
@@ -680,7 +755,7 @@ export const make = <R>(
return {
root: new ToolReference([]),
calls,
keys: (path) => namespaceKeys(tools, path, searchEnabled),
keys: (path) => namespaceKeys(callableTools, path),
invoke: (path, args) =>
Effect.gen(function* () {
const name = path.join(".")
@@ -691,107 +766,7 @@ export const make = <R>(
recordCall(call)
return calls.length - 1
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
if (name === "$codemode.search") {
if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`)
const input = externalArgs[0]
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.",
)
}
const request = input as { query?: unknown; namespace?: unknown; limit?: unknown }
if (request.query !== undefined && typeof request.query !== "string") {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search query must be a string when provided.",
)
}
if (request.namespace !== undefined && typeof request.namespace !== "string") {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search namespace must be a string when provided.",
)
}
if (
request.limit !== undefined &&
(typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)
) {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search limit must be a positive safe integer when provided.",
)
}
const query = typeof request.query === "string" ? request.query : ""
const namespace = typeof request.namespace === "string" ? request.namespace : undefined
const index = yield* recordAndObserve(request)
return yield* observeEnd(
Effect.try({
try: () => {
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
const scoped =
namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
// A query that names one tool path exactly (canonical path or rendered
// JavaScript expression) is a lookup, not a search: return that tool alone.
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact =
pathQuery === ""
? undefined
: scoped.find(
(entry) =>
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const terms = tokenize(query).map(termForms)
// Additive field-weighted scoring, summed across terms: exact path or path
// segment (20) > path substring (8) > description substring (4) > any
// searchable text, incl. input parameter names/descriptions (2). Each term
// matches a field when any of its forms (the term or a singular variant)
// does. An empty query browses everything, alphabetical by path.
const ranked =
exact !== undefined
? [exact]
: scoped
.map((entry) => {
const path = entry.description.path.toLowerCase()
const description = entry.description.description.toLowerCase()
const score = terms.reduce(
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score ||
left.entry.description.path.localeCompare(right.entry.description.path),
)
.map(({ entry }) => entry)
// Result paths are rendered as JavaScript expressions so each `path` is
// directly usable as the call site (`await tools.github.list({ ... })` or
// `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty,
// JSDoc-annotated form (schema descriptions and constraints ride along as
// field comments).
const items = ranked.slice(0, limit).map(({ description, signature }) => ({
...description,
path: toolExpression(description.path),
signature,
}))
return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'")
},
catch: (cause) => cause,
}),
{ index, name, input: request },
)
}
const tool = resolve(tools, path, searchEnabled)
const tool = resolve(callableTools, path)
let describedInput: unknown
if (isDefinition(tool)) {
if (externalArgs.length !== 1)
+2 -2
View File
@@ -187,9 +187,9 @@ const renderSchema = (
if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1)
const lines = properties.map(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
return "unknown"
+17 -2
View File
@@ -27,8 +27,23 @@ export class SandboxSet {
readonly set = new Set<unknown>()
}
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
export class SandboxURLSearchParams {
constructor(readonly params: URLSearchParams) {}
}
export class SandboxURL {
readonly searchParams: SandboxURLSearchParams
constructor(readonly url: URL) {
this.searchParams = new SandboxURLSearchParams(url.searchParams)
}
}
export const isSandboxValue = (
value: unknown,
): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams =>
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
value instanceof SandboxSet ||
value instanceof SandboxURL ||
value instanceof SandboxURLSearchParams
+134 -66
View File
@@ -426,7 +426,7 @@ describe("CodeMode schema flexibility", () => {
{
path: "adapter.call",
description: "Call an adapter-described tool",
signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>",
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<unknown>",
},
])
@@ -459,7 +459,7 @@ describe("CodeMode schema flexibility", () => {
{
path: "users.lookup",
description: "Look up a user",
signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>",
signature: "tools.users.lookup(input: {\n login: string,\n}): Promise<{\n login: string,\n id: number,\n}>",
},
])
@@ -475,7 +475,7 @@ describe("CodeMode schema flexibility", () => {
run: () => Effect.succeed("pong"),
})
const runtime = CodeMode.make({ tools: { net: { ping } } })
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: { host: string }): Promise<unknown>")
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<unknown>")
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
expect(result.ok).toBe(true)
@@ -512,19 +512,19 @@ describe("CodeMode public contract", () => {
{
path: "orders.lookup",
description: "Look up an order by ID",
signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>",
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
},
])
expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
expect(runtime.instructions()).toContain("- orders (1 tool)")
expect(runtime.instructions()).toContain(
" - tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }> // Look up an order by ID",
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
)
// A fully inlined catalog does not advertise search in the instructions...
expect(runtime.instructions()).not.toMatch(/\$codemode/)
// ...but the search tool stays registered, so a speculative call still works. Search
// results carry the pretty multiline signature; the inline catalog stays compact.
// ...but the search tool stays registered, so a speculative call still works with the
// same signature as the inline catalog.
const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`))
expect(result.ok).toBe(true)
if (result.ok) {
@@ -533,10 +533,12 @@ describe("CodeMode public contract", () => {
{
path: "tools.orders.lookup",
description: "Look up an order by ID",
signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>",
signature:
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
},
],
total: 1,
remaining: 0,
next: null,
})
}
})
@@ -554,11 +556,11 @@ describe("CodeMode public contract", () => {
{
path: "context7.resolve-library-id",
description: "Resolve a library ID",
signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
},
])
expect(runtime.instructions()).toContain(
'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
)
const search = await Effect.runPromise(
@@ -571,10 +573,11 @@ describe("CodeMode public contract", () => {
{
path: 'tools.context7["resolve-library-id"]',
description: "Resolve a library ID",
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>',
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
},
],
total: 1,
remaining: 0,
next: null,
})
}
@@ -588,7 +591,7 @@ describe("CodeMode public contract", () => {
runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`),
)
expect(exact.ok).toBe(true)
if (exact.ok) expect((exact.value as { total: number }).total).toBe(1)
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
})
test("instructions use markdown sections with placeholder-only call forms", () => {
@@ -597,23 +600,26 @@ describe("CodeMode public contract", () => {
// Sections in order: workflow at the top, catalog at the bottom.
expect(instructions).toContain("## Workflow")
expect(instructions).toContain("## Rules")
expect(instructions).toContain("## Syntax")
expect(instructions).toContain("## Language")
expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules"))
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax"))
expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list"))
// The workflow carries the result-shape guidance; Rules only add content beyond it.
expect(instructions).toContain(
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Language"))
expect(instructions.indexOf("## Language")).toBeLessThan(
instructions.indexOf("\n## Available tools (COMPLETE list"),
)
expect(instructions).not.toContain("JSON.parse(res)")
expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("raw payloads get truncated and waste context")
expect(instructions).toContain("avoid returning large raw payloads")
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
expect(instructions).toContain("surrounding agent tools are not available unless listed here")
expect(instructions).toContain("Only tools listed here are available inside `tools`")
expect(instructions).toContain("surrounding agent tools are not available")
expect(instructions).toContain("Only Code Mode tools listed here and internal runtime tools")
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
// and no real catalog tools cherry-picked into example lines.
expect(instructions).toContain("`return { <field>: data.<field> }`")
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
expect(instructions).toContain("Return only the fields you need from structured results")
expect(instructions).toContain("check that it is a non-null object and not an array")
expect(instructions).not.toContain("result.<field>")
expect(instructions).not.toContain("data.<field>")
expect(instructions).not.toContain("total_count")
expect(instructions).not.toContain("list_issues")
expect(instructions).not.toContain("tools.orders.lookup({")
@@ -621,38 +627,39 @@ describe("CodeMode public contract", () => {
expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`")
expect(instructions).not.toContain("Browse one namespace")
const partial = CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: 0 } }).instructions()
const partial = CodeMode.make({ tools, discovery: { catalogBudget: 0 } }).instructions()
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
// a query string, never a tool name) and the browse-namespace rule appears.
expect(partial).toContain(
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain("In the next execution, copy a returned path exactly")
expect(partial).toContain(
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`",
"Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools",
)
expect(partial).toContain(
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
)
expect(partial).toContain("repeat the same search with `offset: next.offset`")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("total_count")
expect(partial).not.toContain("tools.orders.lookup({")
})
test("the syntax section names what is unusual or missing, not an allowlist", () => {
test("the language section describes the restricted runtime without overclaiming", () => {
const instructions = CodeMode.make({ tools }).instructions()
// Models already know JavaScript; the section leads with that.
expect(instructions).toContain("Standard modern JavaScript works")
expect(instructions).toContain("TypeScript type annotations are allowed and stripped before execution")
// The not-supported list is derived from (and verified against) the interpreter.
expect(instructions).toContain("Not supported")
for (const missing of ["classes", "generators", "for await...of", ".then/.catch/.finally"]) {
expect(instructions).toContain("restricted JavaScript language for calling tools")
expect(instructions).toContain("not a general-purpose runtime")
expect(instructions).not.toContain("Standard modern JavaScript works")
expect(instructions).not.toContain("TypeScript type annotations")
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) {
expect(instructions).toContain(missing)
}
// Implemented by the DSL-expansion pass, so no longer listed as missing.
expect(instructions).not.toContain("instanceof Error")
expect(instructions).not.toContain("splice")
// The data-boundary note survives.
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
expect(instructions).not.toContain("host globals")
expect(instructions).toContain("Use Code Mode tools for external operations")
expect(instructions).toContain(
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
)
})
@@ -660,7 +667,7 @@ describe("CodeMode public contract", () => {
const runtime = CodeMode.make({})
const instructions = runtime.instructions()
expect(instructions).toContain("No tools are currently available.")
expect(instructions).toContain("## Syntax")
expect(instructions).toContain("## Language")
expect(instructions).toContain("## Available tools")
expect(instructions).not.toContain("## Workflow")
expect(instructions).not.toContain("## Rules")
@@ -682,7 +689,7 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
discovery: { maxInlineCatalogTokens: 0 },
discovery: { catalogBudget: 0 },
})
expect(runtime.instructions()).toContain(
"Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)",
@@ -707,15 +714,16 @@ describe("CodeMode public contract", () => {
{
path: "tools.thread.uploadFile",
description: "Upload one readable local file to the current Discord thread",
signature: "tools.thread.uploadFile(input: {\n path: string\n}): Promise<{\n sent: boolean\n}>",
signature: "tools.thread.uploadFile(input: {\n path: string,\n}): Promise<{\n sent: boolean,\n}>",
},
{
path: "tools.thread.generateImage",
description: "Generate an image and upload it to the current Discord thread",
signature: "tools.thread.generateImage(input: {\n prompt: string\n}): Promise<{\n sent: boolean\n}>",
signature: "tools.thread.generateImage(input: {\n prompt: string,\n}): Promise<{\n sent: boolean,\n}>",
},
],
total: 2,
remaining: 0,
next: null,
})
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
@@ -761,9 +769,14 @@ describe("CodeMode public contract", () => {
const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`))
expect(browse.ok).toBe(true)
if (browse.ok) {
const value = browse.value as { items: Array<{ path: string }>; total: number }
const value = browse.value as {
items: Array<{ path: string }>
remaining: number
next: { offset: number } | null
}
expect(value.items).toHaveLength(10)
expect(value.total).toBe(14)
expect(value.remaining).toBe(4)
expect(value.next).toStrictEqual({ offset: 10 })
}
for (const query of ["many.tool13", "tools.many.tool13"]) {
@@ -777,10 +790,11 @@ describe("CodeMode public contract", () => {
{
path: "tools.many.tool13",
description: "Numbered tool 13",
signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>",
signature: "tools.many.tool13(input: {\n id: string,\n}): Promise<string>",
},
],
total: 1,
remaining: 0,
next: null,
})
}
}
@@ -807,8 +821,8 @@ describe("CodeMode public contract", () => {
)
expect(browse.ok).toBe(true)
if (browse.ok) {
const value = browse.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(2)
const value = browse.value as { items: Array<{ path: string }>; remaining: number }
expect(value.remaining).toBe(0)
expect(value.items.map((item) => item.path)).toStrictEqual([
"tools.github.create_issue",
"tools.github.list_issues",
@@ -821,8 +835,8 @@ describe("CodeMode public contract", () => {
)
expect(scoped.ok).toBe(true)
if (scoped.ok) {
const value = scoped.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(1)
const value = scoped.value as { items: Array<{ path: string }>; remaining: number }
expect(value.remaining).toBe(0)
expect(value.items[0]?.path).toBe("tools.linear.list_issues")
}
@@ -858,8 +872,8 @@ describe("CodeMode public contract", () => {
)
expect(byParameter.ok).toBe(true)
if (byParameter.ok) {
const value = byParameter.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(1)
const value = byParameter.value as { items: Array<{ path: string }>; remaining: number }
expect(value.remaining).toBe(0)
expect(value.items[0]?.path).toBe("tools.files.upload")
}
@@ -869,8 +883,8 @@ describe("CodeMode public contract", () => {
)
expect(bySubstring.ok).toBe(true)
if (bySubstring.ok) {
const value = bySubstring.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(1)
const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number }
expect(value.remaining).toBe(0)
expect(value.items[0]?.path).toBe("tools.files.upload")
}
})
@@ -898,8 +912,8 @@ describe("CodeMode public contract", () => {
)
expect(plural.ok).toBe(true)
if (plural.ok) {
const value = plural.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(1)
const value = plural.value as { items: Array<{ path: string }>; remaining: number }
expect(value.remaining).toBe(0)
expect(value.items[0]?.path).toBe("tools.tracker.fetch_all")
}
@@ -907,8 +921,8 @@ describe("CodeMode public contract", () => {
const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`))
expect(ranked.ok).toBe(true)
if (ranked.ok) {
const value = ranked.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(2)
const value = ranked.value as { items: Array<{ path: string }>; remaining: number }
expect(value.remaining).toBe(0)
expect(value.items.map((item) => item.path)).toStrictEqual([
"tools.github.list_issues",
"tools.tracker.fetch_all",
@@ -934,13 +948,33 @@ describe("CodeMode public contract", () => {
const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`))
expect(browse.ok).toBe(true)
if (browse.ok) {
const value = browse.value as { items: Array<{ path: string }>; total: number }
const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown }
expect(value.items.map((item) => item.path)).toStrictEqual([
"tools.alpha.aardvark",
"tools.alpha.beta",
"tools.zeta.last",
])
expect(value.remaining).toBe(0)
expect(value.next).toBeNull()
}
const middle = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 1 })`),
)
expect(middle.ok).toBe(true)
if (middle.ok) {
expect(middle.value).toMatchObject({
items: [{ path: "tools.alpha.beta" }],
remaining: 1,
next: { offset: 2 },
})
}
const exhausted = await Effect.runPromise(
runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 3 })`),
)
expect(exhausted.ok).toBe(true)
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
})
test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => {
@@ -965,7 +999,7 @@ describe("CodeMode public contract", () => {
// other namespaces from inlining (beta already got its line in the same round).
const runtime = CodeMode.make({
tools: { alpha: { cheap, expensive }, beta: { cheap } },
discovery: { maxInlineCatalogTokens: 40 },
discovery: { catalogBudget: 40 },
})
const instructions = runtime.instructions()
@@ -973,14 +1007,38 @@ describe("CodeMode public contract", () => {
"Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)",
)
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise<string> // Cheap")
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
expect(instructions).not.toContain("tools.alpha.expensive(")
// Fully shown namespaces read cleanly (no "shown" annotation).
expect(instructions).toContain("- beta (1 tool)")
expect(instructions).toContain(" - tools.beta.cheap(input: { q: string }): Promise<string> // Cheap")
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
expect(instructions).toMatch(/\$codemode\.search/)
})
test("charges inline JSDoc against the catalog token budget", () => {
const documented = Tool.make({
description: "Look up a record",
input: {
type: "object",
properties: {
id: { type: "string", description: "A detailed identifier description. ".repeat(20) },
},
required: ["id"],
} as const,
run: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: { records: { lookup: documented } },
discovery: { catalogBudget: 40 },
})
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
expect(runtime.instructions()).toContain(
"Available tools (PARTIAL - 0 of 1 shown; find the rest with tools.$codemode.search)",
)
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
})
test("decodes tool input and output before exposing either side", async () => {
const observed: Array<unknown> = []
const transformed = Tool.make({
@@ -1031,17 +1089,27 @@ describe("CodeMode public contract", () => {
expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError)
expect(() => CodeMode.make({ tools, discovery: { catalogBudget: -1 } })).toThrow(RangeError)
const result = await Effect.runPromise(
CodeMode.make({
tools,
discovery: { maxInlineCatalogTokens: 0 },
discovery: { catalogBudget: 0 },
}).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`),
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.kind).toBe("InvalidToolInput")
for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) {
const invalidOffset = await Effect.runPromise(
CodeMode.make({ tools }).execute(
`return await tools.$codemode.search({ query: "order", offset: ${JSON.stringify(offset)} })`,
),
)
expect(invalidOffset.ok).toBe(false)
if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput")
}
})
test("enforces the tool-call limit as a diagnostic", async () => {
+4 -4
View File
@@ -41,7 +41,7 @@ describe("Object.keys over tool references", () => {
const namespaces = Object.keys(tools)
return { namespaces, count: namespaces.length }
`),
).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
).toEqual({ namespaces: ["github", "memory", "playwright", "$codemode"], count: 4 })
})
test("enumerates tool names at a nested namespace", async () => {
@@ -52,7 +52,7 @@ describe("Object.keys over tool references", () => {
expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([])
})
test("the virtual discovery namespace enumerates its callable surface", async () => {
test("the internal discovery namespace enumerates its callable surface", async () => {
expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"])
})
@@ -137,7 +137,7 @@ describe("for...in", () => {
).toBe("only")
})
test("enumerates namespaces and tools from the host tool tree", async () => {
test("enumerates namespaces and tools from the callable tool tree", async () => {
expect(
await value(`
const names = []
@@ -146,7 +146,7 @@ describe("for...in", () => {
}
return names
`),
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate", "$codemode.search"])
})
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
-1
View File
@@ -204,7 +204,6 @@ describe("OpenAPI.fromSpec", () => {
expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined()
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.changes")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined()
})
+48 -43
View File
@@ -40,21 +40,21 @@ describe("pretty signature rendering", () => {
[
"{",
" /** Repository owner */",
" owner: string",
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string",
" after?: string,",
" /**",
" * Results per page",
" * @default 30",
" */",
" perPage?: number",
" perPage?: number,",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" labels?: Array<string>",
' state?: "open" | "closed"',
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}",
].join("\n"),
)
@@ -83,18 +83,24 @@ describe("pretty signature rendering", () => {
true,
)
expect(pretty).toBe(
["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string", " }", "}"].join(
"\n",
),
[
"{",
" /** Search filter */",
" filter?: {",
" /** Issue state */",
" state?: string,",
" },",
"}",
].join("\n"),
)
})
test("Effect Schema annotations become JSDoc on input and output fields", () => {
expect(inputTypeScript(lookupOrder, true)).toBe(
["{", " /** Order identifier */", " id: string", " verbose?: boolean", "}"].join("\n"),
["{", " /** Order identifier */", " id: string,", " verbose?: boolean,", "}"].join("\n"),
)
expect(outputTypeScript(lookupOrder, true)).toBe(
["{", " /** Current order status */", " status: string", "}"].join("\n"),
["{", " /** Current order status */", " status: string,", "}"].join("\n"),
)
})
@@ -129,7 +135,7 @@ describe("pretty signature rendering", () => {
{ type: "object", properties: { size: { type: "number", default: 1n } } },
true,
)
expect(pretty).toBe(["{", " size?: number", "}"].join("\n"))
expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
@@ -150,7 +156,7 @@ describe("pretty signature rendering", () => {
true,
)
expect(pretty).toBe(
["{", " /**", " * First line", " *", " * Second line", " */", " query?: string", "}"].join("\n"),
["{", " /**", " * First line", " *", " * Second line", " */", " query?: string,", "}"].join("\n"),
)
})
@@ -235,12 +241,12 @@ describe("non-identifier property names render as quoted keys", () => {
expect(jsonSchemaToTypeScript(rawSchema, true)).toBe(
[
"{",
' "123"?: number',
' "foo-bar"?: string',
' "@type": string',
' "123"?: number,',
' "foo-bar"?: string,',
' "@type": string,',
" /** Dotted name */",
' "x.y"?: number',
" plain?: boolean",
' "x.y"?: number,',
" plain?: boolean,",
"}",
].join("\n"),
)
@@ -259,7 +265,7 @@ describe("non-identifier property names render as quoted keys", () => {
})
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string', "}"].join("\n"))
expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string,', "}"].join("\n"))
})
test("Effect Schema structs with non-identifier field names quote too", () => {
@@ -269,7 +275,7 @@ describe("non-identifier property names render as quoted keys", () => {
run: () => Effect.succeed(null),
})
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string', " plain?: number", "}"].join("\n"))
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
})
})
@@ -308,10 +314,7 @@ describe("union schemas render every alternative", () => {
test("allOf renders intersections with parenthesized union members", () => {
const schema = {
allOf: [
{ type: "object", properties: { id: { type: "string" } } },
{ type: ["string", "null"] },
],
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
})
@@ -321,7 +324,9 @@ describe("union schemas render every alternative", () => {
"unknown",
)
expect(
jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }] }),
jsonSchemaToTypeScript({
allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }],
}),
).toBe("unknown")
expect(
jsonSchemaToTypeScript({
@@ -333,7 +338,7 @@ describe("union schemas render every alternative", () => {
})
})
describe("pretty signatures in search results", () => {
describe("JSDoc signatures in catalogs and search results", () => {
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => {
@@ -342,7 +347,7 @@ describe("pretty signatures in search results", () => {
)
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
return result.value as { items: Array<{ path: string; signature: string }>; total: number }
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
}
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
@@ -352,21 +357,21 @@ describe("pretty signatures in search results", () => {
[
"tools.github.list_issues(input: {",
" /** Repository owner */",
" owner: string",
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string",
" after?: string,",
" /**",
" * Results per page",
" * @default 30",
" */",
" perPage?: number",
" perPage?: number,",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" labels?: Array<string>",
' state?: "open" | "closed"',
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}): Promise<unknown>",
].join("\n"),
)
@@ -380,26 +385,26 @@ describe("pretty signatures in search results", () => {
[
"tools.orders.lookup(input: {",
" /** Order identifier */",
" id: string",
" verbose?: boolean",
" id: string,",
" verbose?: boolean,",
"}): Promise<{",
" /** Current order status */",
" status: string",
" status: string,",
"}>",
].join("\n"),
)
}
})
test("the inline catalog line for the same tool stays single-line compact", () => {
test("the inline catalog uses the same JSDoc signatures", async () => {
const instructions = runtime.instructions()
expect(instructions).toContain(
' - tools.github.list_issues(input: { owner: string; after?: string; perPage?: number; labels?: Array<string>; state?: "open" | "closed" }): Promise<unknown> // List issues in a repository',
)
expect(instructions).toContain(
" - tools.orders.lookup(input: { id: string; verbose?: boolean }): Promise<{ status: string }> // Look up an order",
)
expect(instructions).not.toContain("/**")
const github = (await search("list issues repository")).items.find(
({ path }) => path === "tools.github.list_issues",
)!
const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")!
expect(instructions).toContain(` - ${github.signature} // List issues in a repository`)
expect(instructions).toContain(` - ${orders.signature} // Look up an order`)
expect(instructions).toContain("/** Repository owner */")
})
})
@@ -422,7 +427,7 @@ describe("non-identifier tool paths", () => {
const instructions = runtime.instructions()
expect(instructions).toContain(
'tools.context7["resolve-library-id"](input: { query: string; libraryName: string }): Promise<unknown>',
'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
)
expect(instructions).toContain("Do not infer or normalize tool names")
expect(instructions).toContain("bracket notation and quotes are part of the path")
+222 -2
View File
@@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// RegExp/Map/Set -> {}.
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
@@ -149,6 +149,67 @@ describe("RegExp", () => {
expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]")
})
test("function replacers receive captures, offsets, input, and named groups", async () => {
expect(
await value(`
const seen = []
const output = "a1b22".replace(/(\\d)(\\d)?/g, (match, first, second, offset, input) => {
seen.push([match, first, second === undefined, offset, input])
return Number(match) * 2
})
return { output, seen }
`),
).toEqual({
output: "a2b44",
seen: [
["1", "1", true, 1, "a1b22"],
["22", "2", false, 3, "a1b22"],
],
})
expect(
await value(`
return "red-blue".replace(
/(?<left>[a-z]+)-(?<right>[a-z]+)/,
(match, left, right, offset, input, groups) => groups.right + ":" + groups.left,
)
`),
).toBe("blue:red")
})
test("function replacers support string searches, zero-length matches, and result coercion", async () => {
expect(await value(`return "banana".replace("na", (match, offset, input) => "[" + offset + "]")`)).toBe("ba[2]na")
expect(await value(`return "ab".replaceAll("", (match, offset) => offset)`)).toBe("0a1b2")
expect(await value(`return "😀".replaceAll(/(?:)/gu, (match, offset) => "[" + offset + "]")`)).toBe("[0]😀[2]")
expect(
await value(`return "123".replace(/\\d/g, (match) => match === "1" ? 7 : match === "2" ? null : { n: 3 })`),
).toBe("7null[object Object]")
})
test("function replacers can await effectful tool calls", async () => {
const decorate = Tool.make({
description: "Decorate a string",
input: Schema.String,
output: Schema.String,
run: (input) => Effect.succeed(`[${input}]`),
})
const result = await Effect.runPromise(
CodeMode.execute({
tools: { host: { decorate } },
code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`,
}),
)
expect(result.ok && result.value).toBe("a[1]b[22]")
const missingAwait = await Effect.runPromise(
CodeMode.execute({
tools: { host: { decorate } },
code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`,
}),
)
expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue")
expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise")
})
test("replaceAll without the g flag is a catchable error", async () => {
expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught")
})
@@ -208,6 +269,165 @@ describe("RegExp", () => {
})
})
describe("URL and URI helpers", () => {
test("encodes and decodes complete URIs and URI components", async () => {
expect(
await value(`
return [
encodeURI("https://example.test/a b?q=a/b"),
encodeURIComponent("a b/c?"),
decodeURI("https://example.test/a%20b?q=a/b"),
decodeURIComponent("a%20b%2Fc%3F"),
["a b", "c/d"].map(encodeURIComponent),
]
`),
).toEqual([
"https://example.test/a%20b?q=a/b",
"a%20b%2Fc%3F",
"https://example.test/a b?q=a/b",
"a b/c?",
["a%20b", "c%2Fd"],
])
expect(
await value(`try { decodeURIComponent("%zz"); return false } catch (error) { return error instanceof URIError }`),
).toBe(true)
})
test("resolves and mutates URLs with linked search parameters", async () => {
expect(
await value(`
const url = new URL("../users?id=old#top", "https://user:pass@example.com:8443/api/v1/")
url.pathname = "/items/a b"
url.searchParams.set("id", "a b")
url.searchParams.append("tag", "x/y")
url.hash = "part 1"
return {
href: url.href,
origin: url.origin,
host: url.host,
pathname: url.pathname,
search: url.search,
id: url.searchParams.get("id"),
string: String(url),
json: url.toJSON(),
instances: [
url instanceof URL,
url.searchParams instanceof URLSearchParams,
url.searchParams === url.searchParams,
],
}
`),
).toEqual({
href: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
origin: "https://example.com:8443",
host: "example.com:8443",
pathname: "/items/a%20b",
search: "?id=a+b&tag=x%2Fy",
id: "a b",
string: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
json: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
instances: [true, true, true],
})
})
test("URLSearchParams supports records, pairs, mutation, callbacks, and materialization", async () => {
expect(
await value(`
const params = new URLSearchParams([["tag", "b"], ["tag", "a"], ["q", "a b"]])
const seen = []
params.forEach((value, key) => seen.push(key + "=" + value))
params.delete("tag", "b")
params.append("tag", "c")
params.sort()
return {
text: params.toString(),
size: params.size,
tags: params.getAll("tag"),
has: params.has("tag", "c"),
entries: Array.from(params),
object: Object.fromEntries(params),
record: new URLSearchParams({ page: 2, filter: "open" }).toString(),
seen,
}
`),
).toEqual({
text: "q=a+b&tag=a&tag=c",
size: 3,
tags: ["a", "c"],
has: true,
entries: [
["q", "a b"],
["tag", "a"],
["tag", "c"],
],
object: { q: "a b", tag: "c" },
record: "page=2&filter=open",
seen: ["tag=b", "tag=a", "q=a b"],
})
})
test("URL parsing failures are catchable and values use native JSON forms", async () => {
expect(
await value(`
const parsed = URL.parse("/users", "https://example.test/api/")
let invalidIsTypeError = false
try { new URL("not relative without a base") } catch (error) { invalidIsTypeError = error instanceof TypeError }
return {
canParse: URL.canParse("/users", "https://example.test/api/"),
cannotParse: URL.canParse("not relative without a base"),
parsed: parsed.href,
invalidIsTypeError,
boundary: [new URL("https://example.test/a"), new URLSearchParams("q=one")],
json: JSON.stringify({ url: new URL("https://example.test/a"), params: new URLSearchParams("q=one") }),
}
`),
).toEqual({
canParse: true,
cannotParse: false,
parsed: "https://example.test/users",
invalidIsTypeError: true,
boundary: ["https://example.test/a", {}],
json: '{"url":"https://example.test/a","params":{}}',
})
})
test("distinguishes omitted URL arguments from explicit undefined", async () => {
expect(
await value(`
function throwsTypeError(run) {
try { run(); return false } catch (error) { return error instanceof TypeError }
}
const params = new URLSearchParams()
const required = [
() => params.append(),
() => params.delete(),
() => params.get(),
() => params.getAll(),
() => params.has(),
() => params.set(),
() => params.forEach(),
].map(throwsTypeError)
params.append(undefined, undefined)
return {
construct: throwsTypeError(() => new URL()),
canParse: throwsTypeError(() => URL.canParse()),
parse: throwsTypeError(() => URL.parse()),
explicitUndefined: new URL(undefined, "https://example.test/base/").href,
params: params.toString(),
required,
}
`),
).toEqual({
construct: true,
canParse: true,
parse: true,
explicitUndefined: "https://example.test/base/undefined",
params: "undefined=undefined",
required: [true, true, true, true, true, true, true],
})
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(
+1 -1
View File
@@ -102,7 +102,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
description: "Named local directories or Git repositories available as external context",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load",
description: "Ordered plugin enablement directives and external package declarations",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
+2 -2
View File
@@ -1,6 +1,6 @@
export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { AgentV2 } from "../../agent"
@@ -34,7 +34,7 @@ const agentKeys = new Set([
])
export const Plugin = define({
id: "config-agent",
id: "opencode.config.agent",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
+2 -2
View File
@@ -1,6 +1,6 @@
export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { CommandV2 } from "../../command"
@@ -13,7 +13,7 @@ import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = define({
id: "config-command",
id: "opencode.config.command",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
-136
View File
@@ -1,136 +0,0 @@
export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(input): input is PromisePlugin["setup"] => typeof input === "function",
),
}),
]),
})
const PluginPackage = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.String),
module: Schema.optional(Schema.String),
})
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const load = Effect.fn("ConfigExternalPlugin.load")(function* () {
const configured: { package: string; options?: Record<string, unknown> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
for (const item of entry.info.plugins ?? []) {
const ref = typeof item === "string" ? { package: item } : item
const packageName = (() => {
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
return path.resolve(directory, ref.package)
}
return ref.package
})()
configured.push({ package: packageName, options: ref.options })
}
}
if (entry.type === "directory") {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
const directories = yield* fs
.glob("{plugin,plugins}/*", {
cwd: entry.path,
absolute: true,
include: "all",
dot: true,
symlink: true,
})
.pipe(
Effect.flatMap((items) =>
Effect.filter(items, (item) => fs.isDir(item), {
concurrency: "unbounded",
}),
),
Effect.orElseSucceed(() => []),
)
const packages = yield* Effect.forEach(
directories.sort(),
(directory) => resolvePackageEntrypoint(fs, directory),
{ concurrency: "unbounded" },
).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
files.sort()
for (const file of files) configured.push({ package: file })
for (const file of packages) configured.push({ package: file })
}
}
return yield* Effect.forEach(configured, (ref) =>
Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
effect: (host: Parameters<typeof plugin.effect>[0]) =>
plugin.effect({ ...host, options: ref.options ?? {} }),
}
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
for (const plugin of yield* load()) yield* ctx.plugin.add(plugin)
}),
})
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
Effect.catch(() => Effect.succeed(undefined)),
)
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
return yield* Effect.forEach(entries, (entry) => {
if (!entry) return Effect.succeed(undefined)
const file = path.resolve(directory, entry)
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
})

Some files were not shown because too many files have changed in this diff Show More