mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 10:00:48 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca668a7ef8 | |||
| 142ce54e72 | |||
| 2830176972 | |||
| 06dcf3f221 | |||
| f9442e7107 | |||
| ff499c3603 | |||
| 4cee5bd824 | |||
| f406fed924 | |||
| 5b2d90a441 | |||
| 04e8801f5b | |||
| 7f008df79d | |||
| 175d200e53 | |||
| 41d11a8a89 | |||
| 0bba8c780a | |||
| 32cf36de9d | |||
| fb75ea2cf6 | |||
| 147da5d278 | |||
| eed23d8ee9 | |||
| e6e951b252 | |||
| 4e019ba5d9 | |||
| 50a762e7b9 | |||
| 780c99bc2e | |||
| 5bcf8d5a0b | |||
| 652e8ff3fb | |||
| 7b88de47c3 | |||
| 33cb536879 | |||
| 6ffecf9345 | |||
| 08741f6b93 | |||
| baacb2e776 | |||
| a75978815a | |||
| c3d26c4912 | |||
| a9b7bd9e2f |
+3
-3
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,25 +10,46 @@
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"exports": {
|
||||
"./daemon": "./src/daemon.ts",
|
||||
"./mini": "./src/mini/index.ts",
|
||||
"./mini/footer.command": "./src/mini/footer.command.tsx",
|
||||
"./mini/footer.menu": "./src/mini/footer.menu.tsx",
|
||||
"./mini/footer.permission": "./src/mini/footer.permission.tsx",
|
||||
"./mini/footer.prompt": "./src/mini/footer.prompt.tsx",
|
||||
"./mini/footer.question": "./src/mini/footer.question.tsx",
|
||||
"./mini/footer.subagent": "./src/mini/footer.subagent.tsx",
|
||||
"./mini/footer.view": "./src/mini/footer.view.tsx",
|
||||
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
|
||||
"./mini/*": "./src/mini/*.ts",
|
||||
"./server-process": "./src/server-process.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"dev": "bun run src/index.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"opentui-spinner": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
||||
@@ -3,6 +3,31 @@ import { Spec } from "../framework/spec"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
const MiniParams = {
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session when continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
replay: Flag.boolean("replay").pipe(
|
||||
Flag.withDescription("Replay session history on resume and after resize"),
|
||||
Flag.withDefault(true),
|
||||
),
|
||||
replayLimit: Flag.integer("replay-limit").pipe(
|
||||
Flag.withDescription("Cap visible replay to the newest N messages"),
|
||||
Flag.optional,
|
||||
),
|
||||
}
|
||||
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
params: {
|
||||
@@ -88,6 +113,86 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
...MiniParams,
|
||||
project: Argument.string("project").pipe(
|
||||
Argument.withDescription("Path to start OpenCode in"),
|
||||
Argument.optional,
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
server: Flag.string("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
},
|
||||
}),
|
||||
Spec.make("run", {
|
||||
description: "Run OpenCode with a message",
|
||||
params: {
|
||||
message: Argument.string("message").pipe(
|
||||
Argument.withDescription("Message to send"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session before continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
file: Flag.string("file").pipe(
|
||||
Flag.withAlias("f"),
|
||||
Flag.withDescription("File to attach to the message"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
server: Flag.string("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional),
|
||||
variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional),
|
||||
thinking: Flag.boolean("thinking").pipe(
|
||||
Flag.withDescription("Show thinking blocks"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
auto: Flag.boolean("auto").pipe(
|
||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
|
||||
Flag.withDefault(false),
|
||||
Flag.withHidden,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("service", {
|
||||
description: "Manage the background server",
|
||||
commands: [
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Effect, Option, Redacted } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Env } from "../../env"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
||||
const password = yield* Env.password
|
||||
const separator = process.argv.indexOf("--", 2)
|
||||
yield* Effect.promise(() =>
|
||||
runNonInteractive({
|
||||
message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
|
||||
continue: input.continue,
|
||||
session: Option.getOrUndefined(input.session),
|
||||
fork: input.fork,
|
||||
model: Option.getOrUndefined(input.model),
|
||||
agent: Option.getOrUndefined(input.agent),
|
||||
format: input.format,
|
||||
file: [...input.file],
|
||||
title: Option.getOrUndefined(input.title),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
password: password ? Redacted.value(password) : undefined,
|
||||
directory: Option.getOrUndefined(input.dir),
|
||||
variant: Option.getOrUndefined(input.variant),
|
||||
thinking: input.thinking,
|
||||
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"),
|
||||
|
||||
+52
-26
@@ -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,
|
||||
},
|
||||
+1
-1
@@ -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
|
||||
|
||||
+7
-4
@@ -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()
|
||||
+1
-1
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
||||
export {
|
||||
runNonInteractive,
|
||||
mergeInput as mergeNonInteractiveInput,
|
||||
pickRunModel,
|
||||
type RunCommandInput,
|
||||
} from "./run"
|
||||
@@ -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)
|
||||
}
|
||||
+43
-43
@@ -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 {
|
||||
+1
-1
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
+10
-41
@@ -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")
|
||||
}
|
||||
+11
-19
@@ -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()
|
||||
+6
-5
@@ -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,
|
||||
+4
-3
@@ -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)
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+23
-7
@@ -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
|
||||
+19
-23
@@ -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)
|
||||
+45
-51
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
+5
-2
@@ -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 {
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"],
|
||||
])
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+152
-1205
File diff suppressed because it is too large
Load Diff
+14
-3924
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
}) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user