Compare commits

..

60 Commits

Author SHA1 Message Date
Shoubhit Dash ceb5f3554e feat(plugin): bound vcs adapter diffs 2026-07-06 18:21:43 +05:30
Shoubhit Dash 10a71bae4a fix(core): harden vcs backend lifecycle 2026-07-06 18:20:17 +05:30
Shoubhit Dash ef1c76a2bf feat(core): split vcs backend selection 2026-07-06 18:19:13 +05:30
Shoubhit Dash 8ede176244 Merge remote-tracking branch 'origin/v2' into nxl/vcs-plugin-api
# Conflicts:
#	packages/core/src/config.ts
#	packages/core/src/plugin/internal.ts
#	packages/core/src/project.ts
#	packages/core/test/project.test.ts
#	packages/plugin/src/v2/effect/context.ts
#	packages/plugin/src/v2/effect/index.ts
2026-07-06 18:06:51 +05:30
Simon Klee 0bba8c780a cli/mini: fix run failure handling (#35539) 2026-07-06 12:34:44 +02:00
Simon Klee 32cf36de9d mini migrate to v2 (#35526) 2026-07-06 11:03:30 +02:00
Simon Klee fb75ea2cf6 mini: fix default model (#35515) 2026-07-06 09:20:23 +02:00
Dax Raad 147da5d278 test(opencode): update project service stub 2026-07-06 01:32:58 -04:00
Dax Raad eed23d8ee9 feat(api): publish v2 reference 2026-07-06 01:29:08 -04:00
Dax Raad e6e951b252 docs: rebuild v2 documentation 2026-07-06 00:58:00 -04:00
Aiden Cline 4e019ba5d9 fix(core): expose MCP output schemas to code mode (#35502) 2026-07-05 23:12:45 -05:00
Aiden Cline 50a762e7b9 fix(mcp): preserve metadata across paginated tools (#35500) 2026-07-05 22:51:08 -05:00
Aiden Cline 780c99bc2e refactor(core): route questions through forms (#35422) 2026-07-05 22:28:05 -05:00
Aiden Cline 5bcf8d5a0b feat(tui): render session forms (#35421) 2026-07-05 19:35:30 -05:00
Dax Raad 652e8ff3fb fix(client): resolve codegen workspace version 2026-07-05 18:31:15 -04:00
Dax Raad 7b88de47c3 chore(client): publish client package 2026-07-05 18:21:23 -04:00
Dax Raad 33cb536879 feat(plugin): publish generation updates 2026-07-05 17:21:39 -04:00
Dax Raad 6ffecf9345 fix(core): reload local plugins on file changes 2026-07-05 16:54:17 -04:00
Dax Raad 08741f6b93 fix(core): scope observability to server 2026-07-05 16:22:04 -04:00
Dax Raad baacb2e776 fix(core): tolerate invalid OTLP configuration 2026-07-05 16:14:02 -04:00
Dax Raad a75978815a feat(server): add loaded locations debug endpoint 2026-07-05 15:59:44 -04:00
Dax Raad c3d26c4912 feat(core): improve runtime observability 2026-07-05 15:59:44 -04:00
Dax Raad a9b7bd9e2f feat(core): manage configurable plugin generations 2026-07-05 15:59:44 -04:00
Aiden Cline f9d1d3b259 refactor(codemode): namespace public types (#35435) 2026-07-05 11:51:50 -05:00
Aiden Cline f950497173 refactor(codemode): remove generic agent tool (#35420) 2026-07-05 10:41:21 -05:00
Aiden Cline 391aa38281 test(core): serve skill fixtures locally (#35429) 2026-07-05 10:40:55 -05:00
Aiden Cline 29e8502bb0 fix(tui): align integration empty state (#35414) 2026-07-05 09:08:09 -05:00
Kit Langton 44b182fe23 fix(core): validate scalar newtypes (#35381) 2026-07-04 21:43:08 -04:00
Kit Langton 905123b9c0 fix(protocol): keep internal events off SSE (#35378) 2026-07-04 21:12:45 -04:00
Kit Langton bb3b6a2f65 fix(protocol): expose MCP tool change events (#35373) 2026-07-04 20:47:33 -04:00
Aiden Cline b8efb33cde feat(codemode): add OpenAPI tool adapter (#35362) 2026-07-04 16:28:11 -05:00
Aiden Cline d65ecd4a90 feat(core): expose deferred tools through execute (#35361) 2026-07-04 16:20:56 -05:00
Simon Klee 57fb3e5cc5 fix(run): align mini with current session contracts (#35354) 2026-07-04 21:14:01 +02:00
Simon Klee ba07481b59 fix(run): restore subprocess output contracts 2026-07-04 21:06:32 +02:00
Aiden Cline b99759c7de fix(core): enforce mcp tool permissions (#35345) 2026-07-04 13:03:40 -05:00
Aiden Cline c590e27639 feat(core): add grouped and deferred tool registration (#35232) 2026-07-04 11:49:24 -05:00
Kit Langton 8f4b62eb49 fix(core): report missing search paths (#35337) 2026-07-04 12:12:56 -04:00
Kit Langton 945d1c8cb2 fix(core): bound recursive file watching (#35323) 2026-07-04 11:55:05 -04:00
Kit Langton 610e618bc5 fix(tui): clear completed background shell status (#35320) 2026-07-04 11:33:45 -04:00
Kit Langton 9daa4d85a4 fix(tui): reconcile session state after reconnect (#35262) 2026-07-04 11:05:41 -04:00
Kit Langton 62af66a74f fix(tui): distinguish variant switch notices (#35315) 2026-07-04 10:58:56 -04:00
Kit Langton 5b44e5bf41 test(core): release shell test locations (#35271) 2026-07-04 10:54:34 -04:00
Dax Raad a15afbe8f2 bye bye orchestrator 2026-07-04 02:04:58 -04:00
Dax Raad 8e0856c43b fix(core): disable external plugin hot reload 2026-07-04 01:58:17 -04:00
Dax Raad f66c829231 Revert "fix(core): bust external plugin import cache"
This reverts commit afe3ebbc35.
2026-07-04 01:54:30 -04:00
Aiden Cline 3baaabede8 fix(core): resolve mcp header env placeholders (#35236) 2026-07-03 23:00:10 -05:00
Dax Raad afe3ebbc35 fix(core): bust external plugin import cache 2026-07-03 23:12:09 -04:00
Kit Langton e2faeb84e5 fix(core): tolerate minimal FSWatcher typings (#35264) 2026-07-03 22:37:42 -04:00
Kit Langton 35ed09ff37 fix(tui): improve MCP error details (#35263) 2026-07-03 22:26:54 -04:00
Dax Raad 9751615651 fix(core): reload all config plugins 2026-07-03 20:41:33 -04:00
Dax Raad c9b24ef027 feat(core): reload config on filesystem changes 2026-07-03 20:35:04 -04:00
Aiden Cline b04d8d53e6 fix(tui): expand MCP server errors in dialog (#35243) 2026-07-03 18:19:16 -05:00
Dax Raad 7bd3f8ac83 chore(client): remove cyclic test dependencies 2026-07-03 18:12:22 -04:00
Dax Raad 6ae2fa5196 feat(plugin): align hooks with client APIs 2026-07-03 18:12:22 -04:00
Kit Langton 650d774372 refactor(schema): session shell payloads and event prefix restore (#35229) 2026-07-03 17:30:25 -04:00
Simon Klee 64e4f6f91b cli: route run commands through v2 APIs (#35234) 2026-07-03 22:47:04 +02:00
Aiden Cline d097cc8065 feat(tui): render execute child calls on v2 (#35231) 2026-07-03 15:10:25 -05:00
Aiden Cline 438654768c fix(codemode): require exact tool paths in guidance (#35224) 2026-07-03 14:55:03 -05:00
Shoubhit Dash 66878b4c53 feat(plugin): add vcs backend registration api 2026-07-03 18:45:59 +05:30
Shoubhit Dash 38502f7268 feat(core): detect plugin vcs backends from config markers 2026-07-03 18:45:59 +05:30
465 changed files with 69758 additions and 9813 deletions
-36
View File
@@ -1,36 +0,0 @@
export default {
id: "Orchestrator",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("orchestrator", (agent) => {
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
agent.mode = "primary"
agent.system = [
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
"Synthesize minion results, decide next steps, and report back concisely.",
].join("\n")
})
agents.update("minion", (agent) => {
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
agent.mode = "subagent"
agent.model = { providerID: "opencode", id: "glm-5.2" }
agent.system = [
"You are minion, a focused execution subagent for this repository.",
"Complete the specific task delegated to you by Orchestrator using the available tools.",
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
"Do not delegate to other subagents; execute the assigned work yourself.",
].join("\n")
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
})
})
},
}
+19
View File
@@ -104,6 +104,25 @@ bun dev api <operationId> --param key=value
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
+1
View File
@@ -151,6 +151,7 @@ const table = sqliteTable("session", {
## V2 Session Core
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
+12 -2
View File
@@ -101,16 +101,22 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"jsonc-parser": "3.3.1",
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
@@ -122,15 +128,14 @@
},
"packages/client": {
"name": "@opencode-ai/client",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -322,6 +327,7 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
@@ -530,6 +536,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode-ai/httpapi-codegen",
"version": "0.0.0",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -598,6 +605,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/cli": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -695,6 +703,7 @@
"version": "1.17.13",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -794,6 +803,7 @@
"name": "@opencode-ai/server",
"version": "1.17.13",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
@@ -177,7 +177,7 @@ export function DialogCustomProvider(props: Props) {
>
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div>
@@ -226,7 +226,7 @@ const SettingsProvidersContent: Component = () => {
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
@@ -223,7 +223,7 @@ export const SettingsProvidersV2: Component = () => {
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
id="session.synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/new.ts",
event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/open.ts",
event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src",
event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/file.ts",
event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: ".git/index.lock",
event: "change",
+1 -1
View File
@@ -16,7 +16,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "file.watcher.updated") return
if (event.type !== "filesystem.changed") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
+1 -1
View File
@@ -820,7 +820,7 @@ export default function Page() {
)
const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
if (evt.details.type !== "filesystem.changed") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
+22 -1
View File
@@ -10,25 +10,46 @@
"files": [
"bin"
],
"exports": {
"./daemon": "./src/daemon.ts",
"./mini": "./src/mini/index.ts",
"./mini/footer.command": "./src/mini/footer.command.tsx",
"./mini/footer.menu": "./src/mini/footer.menu.tsx",
"./mini/footer.permission": "./src/mini/footer.permission.tsx",
"./mini/footer.prompt": "./src/mini/footer.prompt.tsx",
"./mini/footer.question": "./src/mini/footer.question.tsx",
"./mini/footer.subagent": "./src/mini/footer.subagent.tsx",
"./mini/footer.view": "./src/mini/footer.view.tsx",
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
"./mini/*": "./src/mini/*.ts",
"./server-process": "./src/server-process.ts"
},
"scripts": {
"build": "bun run script/build.ts",
"dev": "bun run src/index.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
"fuzzysort": "catalog:",
"jsonc-parser": "3.3.1",
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:"
"solid-js": "catalog:",
"strip-ansi": "7.1.2"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
+105
View File
@@ -3,6 +3,31 @@ import { Spec } from "../framework/spec"
declare const OPENCODE_CLI_NAME: string | undefined
const MiniParams = {
continue: Flag.boolean("continue").pipe(
Flag.withAlias("c"),
Flag.withDescription("Continue the last session"),
Flag.withDefault(false),
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
Flag.withDescription("Fork the session when continuing"),
Flag.withDefault(false),
),
replay: Flag.boolean("replay").pipe(
Flag.withDescription("Replay session history on resume and after resize"),
Flag.withDefault(true),
),
replayLimit: Flag.integer("replay-limit").pipe(
Flag.withDescription("Cap visible replay to the newest N messages"),
Flag.optional,
),
}
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
@@ -88,6 +113,86 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
...MiniParams,
project: Argument.string("project").pipe(
Argument.withDescription("Path to start OpenCode in"),
Argument.optional,
),
model: Flag.string("model").pipe(
Flag.withAlias("m"),
Flag.withDescription("Model to use in the format provider/model"),
Flag.optional,
),
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
},
}),
Spec.make("run", {
description: "Run OpenCode with a message",
params: {
message: Argument.string("message").pipe(
Argument.withDescription("Message to send"),
Argument.variadic({ min: 0 }),
),
continue: Flag.boolean("continue").pipe(
Flag.withAlias("c"),
Flag.withDescription("Continue the last session"),
Flag.withDefault(false),
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
Flag.withDescription("Fork the session before continuing"),
Flag.withDefault(false),
),
model: Flag.string("model").pipe(
Flag.withAlias("m"),
Flag.withDescription("Model to use in the format provider/model"),
Flag.optional,
),
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
format: Flag.choice("format", ["default", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("default"),
),
file: Flag.string("file").pipe(
Flag.withAlias("f"),
Flag.withDescription("File to attach to the message"),
Flag.atMost(100),
),
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional),
variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional),
thinking: Flag.boolean("thinking").pipe(
Flag.withDescription("Show thinking blocks"),
Flag.withDefault(false),
),
auto: Flag.boolean("auto").pipe(
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
Flag.withDefault(false),
),
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
Flag.withDefault(false),
Flag.withHidden,
),
},
}),
Spec.make("service", {
description: "Manage the background server",
commands: [
@@ -0,0 +1,35 @@
import { Effect, Option, Redacted } from "effect"
import path from "node:path"
import { Commands } from "../commands"
import { Env } from "../../env"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
const { runMini } = yield* Effect.promise(() => import("../../mini"))
const project = Option.getOrUndefined(input.project)
const server = Option.getOrUndefined(input.server)
const password = yield* Env.password
yield* Effect.promise(() =>
runMini({
attach: server,
password: password ? Redacted.value(password) : undefined,
directory:
server !== undefined
? project
: project === undefined
? process.cwd()
: path.resolve(process.env.PWD ?? process.cwd(), project),
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
model: Option.getOrUndefined(input.model),
agent: Option.getOrUndefined(input.agent),
prompt: Option.getOrUndefined(input.prompt),
replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
}),
)
}),
)
+31
View File
@@ -0,0 +1,31 @@
import { Effect, Option, Redacted } from "effect"
import { Commands } from "../commands"
import { Env } from "../../env"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const password = yield* Env.password
const separator = process.argv.indexOf("--", 2)
yield* Effect.promise(() =>
runNonInteractive({
message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
model: Option.getOrUndefined(input.model),
agent: Option.getOrUndefined(input.agent),
format: input.format,
file: [...input.file],
title: Option.getOrUndefined(input.title),
server: Option.getOrUndefined(input.server),
password: password ? Redacted.value(password) : undefined,
directory: Option.getOrUndefined(input.dir),
variant: Option.getOrUndefined(input.variant),
thinking: input.thinking,
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,
}),
)
}),
)
+8 -146
View File
@@ -1,154 +1,16 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect"
import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config"
import { Updater } from "../../services/updater"
import { randomBytes, randomUUID } from "crypto"
import path from "path"
import { ServerProcess } from "../../server-process"
export default Runtime.handler(
Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) {
if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const standalonePassword = yield* Env.password
// Keep the lease credential out of the environment inherited by any
// process this server spawns.
if (input.stdio) {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = input.service ? yield* ServiceConfig.read() : {}
const password = input.service
? yield* ServiceConfig.password()
: standalonePassword
? Redacted.value(standalonePassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
const port = Option.isSome(input.port)
? input.port
: config.port === undefined
? Option.none<number>()
: Option.some(config.port)
const address = yield* listen(hostname, port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
baseUrl: HttpServer.formatAddress(address),
headers: ServerAuth.headers({ password }),
}).v2.health.get({}),
)
if (input.service) yield* register(address)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* input.stdio ? waitForStdinClose() : Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
return yield* ServerProcess.run({
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
hostname: Option.getOrUndefined(input.hostname),
port: Option.getOrUndefined(input.port),
})
}),
)
// Server-side half of the registration protocol. The registration embeds the
// password so the file alone is enough for any client to discover and
// authenticate. The file arbitrates ownership after concurrent starts; it is
// not a startup lock: the atomic rename elects the latest writer, the watcher
// self-evicts losers, and the finalizer id-guard keeps an exiting server from
// deleting its successor's registration.
// Written and read through Service.Info so the file the server registers is
// provably the contract clients discover with.
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
const fs = yield* FileSystem.FileSystem
const { file } = yield* ServiceConfig.options()
const id = randomUUID()
const secret = yield* ServiceConfig.password()
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const encoded = yield* encodeInfo({
id,
version: InstallationVersion,
url: HttpServer.formatAddress(address),
pid: process.pid,
password: secret,
})
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
yield* fs.rename(temp, file)
const currentID = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
yield* currentID.pipe(
Effect.flatMap((current) =>
current === id
? Effect.void
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
),
Effect.repeat(Schedule.spaced("10 seconds")),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() =>
currentID.pipe(
Effect.flatMap((current) => (current === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
})
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
process.stdin.once("end", close)
process.stdin.once("close", close)
process.stdin.resume()
if (process.stdin.readableEnded || process.stdin.destroyed) close()
return Effect.sync(() => {
process.stdin.off("end", close)
process.stdin.off("close", close)
process.stdin.pause()
})
})
}
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> =>
bind(hostname, port, password).pipe(
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
)
return next(4096)
}
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
),
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
}
+70
View File
@@ -0,0 +1,70 @@
import { Service } from "@opencode-ai/client/effect"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Effect } from "effect"
import { ServiceConfig } from "./services/service-config"
export type SharedOptions = {
readonly mode: "shared"
readonly command?: ReadonlyArray<string>
}
export type AttachOptions = {
readonly mode: "attach"
readonly url: string
readonly username?: string
readonly password?: string
}
export type Options = SharedOptions | AttachOptions
const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) {
const transport = {
url: options.url,
headers:
options.password === undefined
? undefined
: ServerAuth.headers({ password: options.password, username: options.username }),
} satisfies Service.Transport
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => attachError(options, cause),
})
if (health.version !== InstallationVersion)
return yield* Effect.fail(
new Error(`Server at ${options.url} has version ${health.version}; this client requires ${InstallationVersion}`),
)
return transport
})
const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) {
const config = yield* ServiceConfig.options()
const service = options.command === undefined ? config : { ...config, command: options.command }
const found = yield* Service.discover(service)
if (found) return found
return yield* Service.start(service)
})
export function transport(options: AttachOptions): ReturnType<typeof attach>
export function transport(options: SharedOptions): ReturnType<typeof shared>
export function transport(options: Options): ReturnType<typeof attach> | ReturnType<typeof shared>
export function transport(options: Options) {
if (options.mode === "attach") return attach(options)
return shared(options)
}
function attachError(options: AttachOptions, cause: unknown) {
if (isUnauthorizedError(cause)) {
return new Error(
options.password === undefined
? `Server at ${options.url} requires authentication; provide a password`
: `Server at ${options.url} rejected the supplied credentials`,
{ cause },
)
}
if (cause instanceof ClientError && cause.reason === "Transport")
return new Error(`Could not reach server at ${options.url}`, { cause })
return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause })
}
export * as Daemon from "./daemon"
+3 -1
View File
@@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
logout: () => import("./commands/handlers/mcp/logout"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
@@ -56,6 +58,6 @@ Effect.logInfo("cli starting", {
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
NodeRuntime.runMain,
)
@@ -1,16 +1,24 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type {
AgentListOutput,
CommandListOutput,
ModelListOutput,
OpenCodeClient,
ProviderListOutput,
SkillListOutput,
} from "@opencode-ai/client/promise"
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number]
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
type CurrentAgent = AgentListOutput["data"][number]
type CurrentCommand = CommandListOutput["data"][number]
type CurrentSkill = SkillListOutput["data"][number]
type CurrentProvider = ProviderListOutput["data"][number]
type CurrentModel = ModelListOutput["data"][number]
function location(directory: string) {
function location(directory: string, workspace?: string) {
return {
location: {
directory,
workspace,
},
}
}
@@ -89,47 +97,65 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
// For explicit --model flows, wait for that exact ref to appear before prompt
// admission. On timeout, return and let the real execution error surface.
export async function waitForCatalogReady(input: {
sdk: OpencodeClient
sdk: OpenCodeClient
directory: string
workspace?: string
model: { providerID: string; modelID: string }
timeoutMs?: number
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline) {
const models = await input.sdk.v2.model
.list(location(input.directory), { throwOnError: true })
.then((result) => result.data?.data ?? [])
const models = await input.sdk.model
.list(location(input.directory, input.workspace))
.then((result) => result.data)
.catch(() => undefined)
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).map(runAgent)
export async function waitForDefaultModel(input: {
sdk: OpenCodeClient
directory: string
timeoutMs?: number
active?: () => boolean
}): Promise<{ providerID: string; modelID: string } | undefined> {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && (input.active?.() ?? true)) {
const model = await input.sdk.model
.default(location(input.directory))
.then((result) => result.data)
.catch(() => undefined)
if (model) return { providerID: model.providerID, modelID: model.id }
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise<RunCommand[]> {
export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.agent.list(location(directory))
return result.data.map(runAgent)
}
export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise<RunCommand[]> {
const [commands, skills] = await Promise.all([
sdk.v2.command.list(location(directory), { throwOnError: true }),
sdk.v2.skill.list(location(directory), { throwOnError: true }),
sdk.command.list(location(directory)),
sdk.skill.list(location(directory)),
])
return [
...(commands.data?.data ?? []).map(runCommand),
...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill),
...commands.data.map(runCommand),
...skills.data.filter((skill) => skill.slash !== false).map(runSkill),
]
}
export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.v2.reference.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).filter((reference) => !reference.hidden)
export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
const result = await sdk.reference.list(location(directory))
return result.data.filter((reference) => !reference.hidden)
}
export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise<RunProvider[]> {
export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise<RunProvider[]> {
const [providers, models] = await Promise.all([
sdk.v2.provider.list(location(directory), { throwOnError: true }),
sdk.v2.model.list(location(directory), { throwOnError: true }),
sdk.provider.list(location(directory)),
sdk.model.list(location(directory)),
])
return runProviders(providers.data?.data ?? [], models.data?.data ?? [])
return runProviders([...providers.data], [...models.data])
}
@@ -678,7 +678,7 @@ function emitTask(state: State): void {
state: {
status: "running",
input: {
filePath: "packages/opencode/src/cli/cmd/run/stream.ts",
filePath: "packages/cli/src/mini/stream.ts",
offset: 1,
limit: 200,
},
@@ -3,7 +3,7 @@ import { TextAttributes, type ColorInput } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { transparent, type RunFooterTheme } from "./theme"
import * as Locale from "@/util/locale"
import { Locale } from "@opencode-ai/tui/util/locale"
export const FOOTER_MENU_ROWS = 8
@@ -8,11 +8,11 @@
import { pathToFileURL } from "bun"
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "@opencode-ai/tui/editor"
import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
import fuzzysort from "fuzzysort"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import * as Locale from "@/util/locale"
import { Locale } from "@opencode-ai/tui/util/locale"
import {
createPromptHistory,
displayCharAt,
@@ -67,7 +67,7 @@ type PromptInput = {
prompt: Accessor<boolean>
width: Accessor<number>
theme: Accessor<RunFooterTheme>
history?: RunPrompt[]
history?: Accessor<RunPrompt[]>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
@@ -175,14 +175,18 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
return { type: "pending" as const }
}
if (!commands.some((item) => item.name === head.name)) {
const item = commands.find((entry) => entry.name === head.name)
if (!item) {
return { type: "none" as const }
}
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
return {
type: "command" as const,
command: { name: head.name, arguments: head.arguments, ...(item.source ? { source: item.source } : {}) },
}
}
function selectedCommand(text: string, command: RunPrompt["command"]) {
export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) {
if (!command) {
return
}
@@ -192,9 +196,14 @@ function selectedCommand(text: string, command: RunPrompt["command"]) {
return
}
// Bound drafts (e.g. the skill picker) may predate or omit the catalog
// source; resolve it at submit time so routing never degrades to a plain
// command for a skill entry.
const source = command.source ?? commands?.find((item) => item.name === command.name)?.source
return {
name: command.name,
arguments: head.arguments,
...(source ? { source } : {}),
}
}
@@ -293,7 +302,10 @@ export function createPromptState(input: PromptInput): PromptState {
return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
})
let history = createPromptHistory(input.history)
let history = createPromptHistory(input.history?.())
createEffect(() => {
history = createPromptHistory(input.history?.())
})
let draft: RunPrompt = { text: "", parts: [] }
let stash: RunPrompt = { text: "", parts: [] }
let area: TextareaRenderable | undefined
@@ -1178,7 +1190,7 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands())
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
@@ -203,6 +203,8 @@ export class RunFooter implements FooterApi {
private setSubagent: (next: FooterSubagentState) => void
private queuedPrompts: Accessor<FooterQueuedPrompt[]>
private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
private history: Accessor<RunPrompt[]>
private setHistory: Setter<RunPrompt[]>
private promptRoute: FooterPromptRoute = { type: "composer" }
private subagentMenuRows = SUBAGENT_ROWS
private autocomplete = false
@@ -289,6 +291,9 @@ export class RunFooter implements FooterApi {
const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
this.queuedPrompts = queuedPrompts
this.setQueuedPrompts = setQueuedPrompts
const [history, setHistory] = createSignal(options.history ?? [])
this.history = history
this.setHistory = setHistory
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
this.scrollback = this.createScrollback(options.wrote ?? false)
@@ -322,7 +327,7 @@ export class RunFooter implements FooterApi {
diffStyle: options.diffStyle,
tuiConfig: options.tuiConfig,
backgroundSubagents: options.backgroundSubagents,
history: options.history,
history: footer.history,
agent: options.agentLabel,
onSubmit: footer.handlePrompt,
onPermissionReply: footer.handlePermissionReply,
@@ -390,6 +395,15 @@ export class RunFooter implements FooterApi {
}
public event(next: FooterEvent): void {
if (next.type === "history") {
this.setHistory(next.history)
return
}
if (next.type === "model") {
this.setCurrentModel(next.selection)
}
if (next.type === "turn.duration") {
const current = this.currentModel()
this.flush()
@@ -88,7 +88,7 @@ type RunFooterViewProps = {
diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig
backgroundSubagents: boolean
history?: RunPrompt[]
history?: () => RunPrompt[]
agent: string
onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>
@@ -781,6 +781,7 @@ export function RunFooterView(props: RunFooterViewProps) {
command: {
name,
arguments: "",
source: "skill",
},
})
closePanel()
+7
View File
@@ -0,0 +1,7 @@
export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
export {
runNonInteractive,
mergeInput as mergeNonInteractiveInput,
pickRunModel,
type RunCommandInput,
} from "./run"
+234
View File
@@ -0,0 +1,234 @@
import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { truthy } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
import path from "node:path"
import { Daemon } from "../daemon"
import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types"
export type MiniCommandInput = {
directory?: string
attach?: string
password?: string
username?: string
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
prompt?: string
replay?: boolean
replayLimit?: number
demo?: boolean
serverCommand?: ReadonlyArray<string>
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
}
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
type Transport = { readonly url: string; readonly headers?: HeadersInit }
export async function runMini(input: MiniCommandInput) {
validate(input)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
const runtimeTask = import("./runtime")
const directory = input.attach ? input.directory : localDirectory(input.directory)
const transportTask = startTransport(input)
void transportTask.catch(() => {})
try {
if (input.attach) await transportTask
const sdk = OpenCode.make({
baseUrl: "http://opencode.pending",
fetch: deferredFetch(transportTask),
})
const attachedSession =
input.attach && input.session && !input.directory
? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
: undefined
const resolvedDirectory =
directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk))
const model = parseModel(input.model)
let agentTask: Promise<string | undefined> | undefined
const resolveAgent = () => {
agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach)
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([
resolveAgent(),
selectSession(sdk, resolvedDirectory, input, attachedSession),
])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel })
const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model))
return { id: session.id, title: session.title, resume: selected !== undefined }
}
const create = (
_sdk: OpenCodeClient,
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant)
const runtime = await runtimeTask
await runtime.runInteractiveDeferredMode({
sdk,
directory: resolvedDirectory,
resolveAgent,
session: resolveSession,
createSession: create,
agent: input.agent,
model,
variant: undefined,
files: [],
initialInput,
thinking: true,
backgroundSubagents:
truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") ||
(process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")),
replay: input.replay ?? true,
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
})
} catch (error) {
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message)
throw error
}
}
/** @internal Exported for testing. */
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
if (!prompt) return piped || undefined
if (!piped) return prompt
return piped + "\n" + prompt
}
function validate(input: MiniCommandInput) {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
fail("--replay-limit must be a positive integer")
}
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
resolveInteractiveStdin().cleanup?.()
}
function localDirectory(directory?: string): string {
const root = process.env.PWD ?? process.cwd()
try {
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
return process.cwd()
} catch {
fail(`Failed to change directory to ${directory}`)
}
}
function startTransport(input: MiniCommandInput): Promise<Transport> {
if (input.attach) {
return Effect.runPromise(
Daemon.transport({
mode: "attach",
url: input.attach,
password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
}),
)
}
return Effect.runPromise(
Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe(
Effect.provide(NodeFileSystem.layer),
Effect.provide(Global.layerWith({})),
),
)
}
function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch {
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const transport = await transportTask
const request = new Request(input, init)
const source = new URL(request.url)
const headers = new Headers(request.headers)
for (const [key, value] of new Headers(transport.headers)) headers.set(key, value)
return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers })
}
return fetch as typeof globalThis.fetch
}
async function remoteDirectory(
transport: { url: string; headers?: HeadersInit },
sdk: OpenCodeClient,
): Promise<string> {
const location = await sdk.location.get()
if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`)
return location.directory
}
function parseModel(value?: string): RunInput["model"] {
if (!value) return
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) fail("--model must use the format provider/model")
return { providerID, modelID }
}
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline) {
agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
await Bun.sleep(25)
}
if (!agents) {
warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`)
return
}
warning(`agent "${name}" not found. Falling back to default agent`)
}
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
const selected =
preselected ??
(input.session
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await sdk.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined)
if (input.session && !selected) fail("Session not found")
if (!selected) return
if (!input.fork) return selected
return sdk.session.fork({ sessionID: selected.id })
}
async function createSession(
sdk: OpenCodeClient,
directory: string,
agent: string | undefined,
model: RunInput["model"],
variant?: string,
): Promise<Session> {
if (model) await waitForCatalogReady({ sdk, directory, model })
return sdk.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory },
})
}
function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
}
function fail(message: string): never {
process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
process.exit(1)
}
@@ -1,15 +1,17 @@
import type {
OpencodeClient,
EventSubscribeOutput,
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type {
ReasoningPart,
StepFinishPart,
StepStartPart,
TextPart,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { MessageID } from "@/session/schema"
import { UI } from "../../ui"
import { UI } from "./ui"
type Model = {
providerID: string
@@ -23,7 +25,7 @@ type File = {
}
type Input = {
client: OpencodeClient
client: OpenCodeClient
sessionID: string
message: string
files: File[]
@@ -52,6 +54,7 @@ type ToolState = StartedPart & {
provider?: unknown
}
type V2Event = EventSubscribeOutput
type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
// MCP elicitations are temporarily owned by the "global" sentinel instead of a real
@@ -61,16 +64,11 @@ const GLOBAL_FORM_SESSION_ID = "global"
export async function runNonInteractivePrompt(input: Input) {
const controller = new AbortController()
const events = await input.client.v2.event.subscribe({
signal: controller.signal,
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<V2Event>
const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("Event stream disconnected before prompt admission")
const messageID = MessageID.ascending()
const messageID = SessionMessage.ID.create()
const starts = new Map<string, StartedPart>()
const tools = new Map<string, ToolState>()
let submitted = false
@@ -101,7 +99,7 @@ export async function runNonInteractivePrompt(input: Input) {
UI.empty()
}
const replyPermission = async (request: { id: string; action: string; resources: string[] }) => {
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.dangerouslySkipPermissions) {
permissionRejected = true
UI.println(
@@ -110,7 +108,7 @@ export async function runNonInteractivePrompt(input: Input) {
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
)
}
await input.client.v2.session.permission
await input.client.permission
.reply({
sessionID: input.sessionID,
requestID: request.id,
@@ -118,24 +116,30 @@ export async function runNonInteractivePrompt(input: Input) {
})
.catch(() => {})
if (!input.dangerouslySkipPermissions) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
}
const rejectQuestion = async (request: { id: string }) => {
questionRejected = true
await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
}
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
formCancelled = true
await input.client.v2.session.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
}
const consume = async () => {
while (!controller.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("Event stream disconnected during prompt execution")
const next = await stream.next().catch((error) => {
if (!emittedError) throw error
return { done: true as const, value: undefined }
})
if (next.done) {
if (emittedError) return
throw new Error("Event stream disconnected during prompt execution")
}
const event = next.value
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
@@ -156,16 +160,16 @@ 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 === "prompt.promoted") {
if (event.type === "session.prompt.promoted") {
if (event.data.inputID === messageID) {
promoted = true
continue
}
}
if (
event.type === "execution.settled" &&
event.type === "session.execution.settled" &&
event.data.outcome === "interrupted" &&
(interrupted || permissionRejected || questionRejected || formCancelled)
) {
@@ -173,7 +177,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (!promoted) continue
if (event.type === "step.started") {
if (event.type === "session.step.started") {
const part: StepStartPart = {
id: partID(event.id),
sessionID: input.sessionID,
@@ -189,11 +193,11 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "text.started") {
if (event.type === "session.text.started") {
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "text.ended") {
if (event.type === "session.text.ended") {
const started = starts.get(event.data.textID)
const part: TextPart = {
id: started?.id ?? partID(event.id),
@@ -207,11 +211,11 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "reasoning.started") {
if (event.type === "session.reasoning.started") {
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "reasoning.ended" && input.thinking) {
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get(event.data.reasoningID)
const part: ReasoningPart = {
id: started?.id ?? partID(event.id),
@@ -236,7 +240,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "tool.input.started") {
if (event.type === "session.tool.input.started") {
tools.set(event.data.callID, {
id: partID(event.id),
timestamp: time,
@@ -246,12 +250,12 @@ export async function runNonInteractivePrompt(input: Input) {
})
continue
}
if (event.type === "tool.input.ended") {
if (event.type === "session.tool.input.ended") {
const current = tools.get(event.data.callID)
if (current) current.raw = event.data.text
continue
}
if (event.type === "tool.called") {
if (event.type === "session.tool.called") {
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
id: current?.id ?? partID(event.id),
@@ -264,7 +268,7 @@ export async function runNonInteractivePrompt(input: Input) {
})
continue
}
if (event.type === "tool.success") {
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = {
id: current.id,
@@ -297,7 +301,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue
}
if (event.type === "tool.failed") {
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const part: ToolPart = {
@@ -328,7 +332,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "step.ended") {
if (event.type === "session.step.ended") {
const part: StepFinishPart = {
id: partID(event.id),
sessionID: input.sessionID,
@@ -342,14 +346,14 @@ export async function runNonInteractivePrompt(input: Input) {
emit("step_finish", time, { part })
continue
}
if (event.type === "step.failed") {
if (event.type === "session.step.failed") {
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "execution.settled") {
if (event.type === "session.execution.settled") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
@@ -367,34 +371,31 @@ export async function runNonInteractivePrompt(input: Input) {
interrupted = true
process.exitCode = 130
admission?.abort()
void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
void input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
process.on("SIGINT", interrupt)
let completed: Promise<void> | undefined
try {
if (input.agent) {
await input.client.v2.session.switchAgent(
{ sessionID: input.sessionID, agent: input.agent },
{ throwOnError: true },
)
await input.client.session.switchAgent({ sessionID: input.sessionID, agent: input.agent })
}
const selected = input.model
? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
: input.variant
? await input.client.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true })
.then((result) => result.data.data.model)
? await input.client.session
.get({ sessionID: input.sessionID })
.then((result) => result.model)
.then(async (model) => {
if (model) return { ...model, variant: input.variant }
const result = await input.client.v2.model.default(undefined, { throwOnError: true })
const fallback = result.data.data
const result = await input.client.model.default()
const fallback = result.data
return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
})
: undefined
if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected) {
await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true })
await input.client.session.switchModel({ sessionID: input.sessionID, model: selected })
}
const prepared = await Promise.all(input.files.map(prepareFile))
@@ -402,7 +403,7 @@ export async function runNonInteractivePrompt(input: Input) {
submitted = true
completed = consume()
admission = new AbortController()
const response = await input.client.v2.session
const response = await input.client.session
.prompt(
{
sessionID: input.sessionID,
@@ -413,35 +414,34 @@ export async function runNonInteractivePrompt(input: Input) {
},
delivery: "steer",
},
{ throwOnError: true, signal: admission.signal },
{ signal: admission.signal },
)
.catch(async (error) => {
if (interrupted) {
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
controller.abort()
await completed?.catch(() => {})
if (interrupted) return undefined
if (interrupted || emittedError) return undefined
throw error
})
admission = undefined
if (!response) return
if (!response.data.data) throw new Error("Prompt was not admitted")
if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, questions, forms] = await Promise.all([
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
Promise.all(
(input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
input.client.v2.session.form.list({ sessionID }).catch(() => undefined),
input.client.form.list({ sessionID }).catch(() => undefined),
),
),
])
await Promise.all([
...(permissions?.data?.data ?? []).map(replyPermission),
...(questions?.data?.data ?? []).map(rejectQuestion),
...forms.flatMap((response) => response?.data?.data ?? []).map(cancelForm),
...(permissions ?? []).map(replyPermission),
...(questions ?? []).map(rejectQuestion),
...forms.flatMap((response) => response ?? []).map(cancelForm),
])
await completed
} finally {
@@ -7,7 +7,7 @@
// the current browse position. When the user arrows up at cursor offset 0,
// the current draft is saved and history begins. Arrowing past the end
// restores the draft.
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
export { displayCharAt, displaySlice, mentionTriggerIndex } from "@opencode-ai/tui/prompt/display"
import type { RunPrompt } from "./types"
const HISTORY_LIMIT = 200
+311
View File
@@ -0,0 +1,311 @@
import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Global } from "@opencode-ai/core/global"
import { FSUtil } from "@opencode-ai/core/fs-util"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { open } from "node:fs/promises"
import path from "node:path"
import { Daemon } from "../daemon"
import { Standalone } from "../services/standalone"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
import { UI } from "./ui"
export type RunCommandInput = {
message: string[]
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
format: "default" | "json"
file: string[]
title?: string
server?: string
password?: string
username?: string
directory?: string
variant?: string
thinking?: boolean
dangerouslySkipPermissions?: boolean
standaloneCommand?: ReadonlyArray<string>
}
type FilePart = {
url: string
filename: string
mime: string
}
type Transport = { readonly url: string; readonly headers?: HeadersInit }
type Prepared = {
directory?: string
message: string
files: FilePart[]
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
export function runNonInteractive(input: RunCommandInput) {
return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error)))
}
async function run(input: RunCommandInput) {
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
const root = process.env.PWD ?? process.cwd()
const directory = input.server ? input.directory : localDirectory(input.directory, root)
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
if (!message?.trim()) fail("You must provide a message")
const files = await Promise.all(
input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)),
)
const prepared = { directory, message, files }
if (input.standaloneCommand)
return Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* Standalone.transport({ command: input.standaloneCommand })
yield* Effect.promise(() => execute(input, prepared, transport))
}),
),
)
const transport = await startTransport(input)
return execute(input, prepared, transport)
}
async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) {
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory")
const session = await selectSession(client, requestedDirectory, input)
const cwd = session?.location.directory ?? requestedDirectory
const workspace = session?.location.workspaceID
const explicitModel = parseModel(input.model)
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
const defaultModel =
!explicitModel && !sessionModel
? await client.model
.default({ location: { directory: cwd, workspace } })
.then((result) =>
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
)
: undefined
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
}
const agent = await validateAgent(client, cwd, input.agent, input.server)
const selected =
session ??
(await client.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant: input.variant } : undefined,
location: { directory: cwd },
}))
if (!session && input.title !== undefined) {
await client.session.rename({
sessionID: selected.id,
title:
input.title ||
prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
}
await runNonInteractivePrompt({
client,
sessionID: selected.id,
message: prepared.message,
files: prepared.files,
agent,
model,
variant: input.variant,
thinking: input.thinking ?? false,
format: input.format,
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
attached: !input.standaloneCommand,
renderTool,
renderToolError,
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
}
export function mergeInput(message: string | undefined, piped: string | undefined) {
if (!message) return piped || undefined
if (!piped) return message
return message + "\n" + piped
}
export function pickRunModel(
explicit: { providerID: string; modelID: string } | undefined,
variant: string | undefined,
session: { providerID: string; modelID: string } | undefined,
fallback: { providerID: string; modelID: string } | undefined,
) {
if (explicit) return explicit
if (!variant) return
return session ?? fallback
}
function formatMessage(message: string[]) {
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
return value || undefined
}
function localDirectory(directory: string | undefined, root: string) {
try {
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
return process.cwd()
} catch {
fail(`Failed to change directory to ${directory}`)
}
}
function startTransport(input: RunCommandInput) {
if (input.server) {
return Effect.runPromise(
Daemon.transport({
mode: "attach",
url: input.server,
password: input.password,
username: input.username,
}),
)
}
return Effect.runPromise(
Daemon.transport({ mode: "shared" }).pipe(
Effect.provide(NodeFileSystem.layer),
Effect.provide(Global.layerWith({})),
),
)
}
function parseModel(value?: string) {
if (!value) return
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) fail("--model must use the format provider/model")
return { providerID, modelID }
}
async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) {
if (!name) return
const agents = await loadRunAgents(client, directory).catch(() => undefined)
if (!agents) {
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
return
}
const agent = agents.find((item) => item.name === name)
if (!agent) {
warning(`agent "${name}" not found. Falling back to default agent`)
return
}
if (agent.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
return name
}
async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
const selected = input.session
? await client.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await client.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined
if (input.session && !selected) fail("Session not found")
if (!selected || !input.fork) return selected
return client.session.fork({ sessionID: selected.id })
}
async function prepareFile(input: string, directory: string, remote: boolean): Promise<FilePart> {
const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
try {
const stat = await handle.stat()
if (remote && stat.isDirectory()) fail(`Cannot attach local directory without a shared filesystem: ${input}`)
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
const content = Buffer.alloc(Number(stat.size))
let offset = 0
while (offset < content.length) {
const read = await handle.read(content, offset, content.length - offset, offset)
if (read.bytesRead === 0) break
offset += read.bytesRead
}
const bytes = content.subarray(0, offset)
const detected = FSUtil.mimeType(file)
const text = bytes.toString("utf8")
const mime =
detected.startsWith("image/") || detected === "application/pdf"
? detected
: !isBinaryContent(bytes) && Buffer.from(text, "utf8").equals(bytes)
? "text/plain"
: detected
return {
url: `data:${mime};base64,${bytes.toString("base64")}`,
filename: path.basename(file),
mime,
}
} finally {
await handle.close()
}
}
function isBinaryContent(bytes: Uint8Array) {
if (bytes.length === 0) return false
if (bytes.includes(0)) return true
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
}
async function renderTool(part: ToolPart) {
const info = toolInlineInfo(part)
if (info.mode === "block") {
UI.empty()
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
if (info.body?.trim()) UI.println(info.body)
UI.empty()
return
}
UI.println(
UI.Style.TEXT_NORMAL + info.icon,
UI.Style.TEXT_NORMAL + info.title,
info.description ? UI.Style.TEXT_DIM + info.description + UI.Style.TEXT_NORMAL : "",
)
}
async function renderToolError(part: ToolPart) {
const info = toolInlineInfo(part)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
function warning(message: string) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
}
function reportError(input: RunCommandInput, message: string, sessionID?: string) {
process.exitCode = 1
if (input.format === "json") {
process.stdout.write(
JSON.stringify({
type: "error",
timestamp: Date.now(),
sessionID: sessionID ?? "",
error: { type: "unknown", message },
}) + "\n",
)
return
}
UI.error(message)
}
function fail(message: string): never {
throw new Error(message)
}
@@ -7,12 +7,10 @@
// none block each other.
import { Context, Effect, Layer } from "effect"
import { resolve } from "@opencode-ai/tui/config"
import { TuiConfig } from "@/config/tui"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { loadRunProviders } from "./catalog.shared"
import { reusePendingTask } from "./runtime.shared"
import { resolveCurrentSession, sessionHistory } from "./session.shared"
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
import { pickVariant } from "./variant.shared"
@@ -26,10 +24,10 @@ export type ModelInfo = {
export type SessionInfo = {
first: boolean
history: RunPrompt[]
model?: NonNullable<RunInput["model"]>
variant: string | undefined
}
type Config = Awaited<ReturnType<typeof TuiConfig.get>>
type BootService = {
readonly resolveModelInfo: (
sdk: RunInput["sdk"],
@@ -41,18 +39,10 @@ type BootService = {
sessionID: string,
model: RunInput["model"],
) => Effect.Effect<SessionInfo>
readonly resolveRunTuiConfig: () => Effect.Effect<RunTuiConfig>
readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
}
const configTask: { current?: Promise<Config> } = {}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get())
}
function emptyModelInfo(): ModelInfo {
return {
providers: [],
@@ -76,23 +66,9 @@ function defaultRunTuiConfig(): RunTuiConfig {
}
}
function runTuiConfig(config: Config | undefined): RunTuiConfig {
if (!config) {
return defaultRunTuiConfig()
}
return {
keybinds: config.keybinds,
leader_timeout: config.leader_timeout,
diff_style: config.diff_style ?? "auto",
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined)))
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
sdk: RunInput["sdk"],
directory: string,
@@ -141,23 +117,14 @@ const layer = Layer.effect(
return {
first: session.first,
history: sessionHistory(session),
variant: pickVariant(model, session),
model: session.model,
variant: pickVariant(model ?? session.model, session),
}
})
const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () {
return runTuiConfig(yield* config())
})
const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
return runTuiConfig(yield* config()).diff_style ?? "auto"
})
return Service.of({
resolveModelInfo,
resolveSessionInfo,
resolveRunTuiConfig,
resolveDiffStyle,
})
}),
)
@@ -174,6 +141,10 @@ export async function resolveModelInfo(
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo())
}
export function resolveModelInfoStrict(sdk: RunInput["sdk"], directory: string, model: RunInput["model"]) {
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model))
}
// Fetches session messages to determine if this is the first turn and build prompt history.
export async function resolveSessionInfo(
sdk: RunInput["sdk"],
@@ -184,10 +155,12 @@ export async function resolveSessionInfo(
}
// Reads TUI config once for direct mode keymap setup and display preferences.
export async function resolveRunTuiConfig(): Promise<RunTuiConfig> {
return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig())
export async function resolveRunTuiConfig(
config?: RunTuiConfig | Promise<RunTuiConfig>,
): Promise<RunTuiConfig> {
return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig())
}
export async function resolveDiffStyle(): Promise<RunDiffStyle> {
return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
export async function resolveDiffStyle(config?: RunTuiConfig | Promise<RunTuiConfig>): Promise<RunDiffStyle> {
return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto")
}
@@ -12,10 +12,9 @@ import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global"
import { openEditor } from "@opencode-ai/tui/editor"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { Session as SessionApi } from "@/session/session"
import * as Locale from "@/util/locale"
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
import { Locale } from "@opencode-ai/tui/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
@@ -64,7 +63,7 @@ export type LifecycleInput = {
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
tuiConfig: RunTuiConfig
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
backgroundSubagents: boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void>
@@ -108,7 +107,7 @@ function shutdown(renderer: CliRenderer): void {
}
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !SessionApi.isDefaultTitle(title)) {
if (title && !isDefaultTitle(title)) {
return {
title,
showSession: true,
@@ -124,17 +123,9 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) {
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
const agentLabel = Locale.titlecase(input.agent ?? "build")
if (!input.model) {
return {
agentLabel,
modelLabel: "Model default",
}
}
return {
agentLabel,
modelLabel: formatModelLabel(input.model, input.variant),
modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
}
}
@@ -176,6 +167,7 @@ function queueSplash(
// the entry splash, RunFooter takes over the footer region.
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const source = resolveInteractiveStdin()
const footerTask = import("./footer")
let unregisterKeymap: (() => void) | undefined
try {
@@ -194,10 +186,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
consoleMode: "disabled",
clearOnShutdown: false,
})
const theme = await resolveRunTheme(renderer)
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
renderer.setBackgroundColor(theme.background)
const keymap = createDefaultOpenTuiKeymap(renderer)
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const state: SplashState = {
entry: false,
exit: false,
@@ -212,7 +204,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
model: input.model,
variant: input.variant,
})
const footerTask = import("./footer")
const wrote = queueSplash(
renderer,
state,
@@ -244,9 +235,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
theme,
wrote,
keymap,
tuiConfig: input.tuiConfig,
tuiConfig,
backgroundSubagents: input.backgroundSubagents,
diffStyle: input.tuiConfig.diff_style ?? "auto",
diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
onQuestionReject: input.onQuestionReject,
@@ -260,6 +251,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
return
}
const { openEditor } = await import("@opencode-ai/tui/editor")
await renderer.idle().catch(() => {})
const ignore = () => {}
detachSigint()
@@ -8,8 +8,9 @@
// and tracks per-turn wall-clock duration for the footer status line.
//
// Resolves when the footer closes and all in-flight work finishes.
import * as Locale from "@/util/locale"
import { MessageID, PartID } from "@/session/schema"
import { ascending } from "@opencode-ai/schema/identifier"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "@opencode-ai/tui/util/locale"
import { isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
@@ -167,7 +168,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
? prompt
: {
...prompt,
messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(),
messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(),
}
state.active = sent
@@ -285,8 +286,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
!isNewCommand(prompt.text)
) {
const queued: FooterQueuedPrompt = {
messageID: MessageID.ascending(),
partID: PartID.ascending(),
messageID: SessionMessage.ID.create(),
partID: "prt_" + ascending(),
prompt,
}
state.queued = [...state.queued, queued]
@@ -4,7 +4,7 @@
// and prompt queue together into a single session loop. Two entry points:
//
// runInteractiveMode -- used when an SDK client already exists (attach mode)
// runInteractiveLocalMode -- used for local in-process mode (no server)
// runInteractiveDeferredMode -- paints before resolving its session
//
// Both delegate to runInteractiveRuntime, which:
// 1. resolves TUI config, model info, and session history,
@@ -12,16 +12,22 @@
// 3. starts the stream transport (SDK event subscription), lazily for fresh
// local sessions,
// 4. runs the prompt queue until the footer closes.
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageID } from "@/session/schema"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
import { createRunDemo } from "./demo"
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { trace } from "./trace"
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types"
import type {
LocalReplayAnchor,
LocalReplayRow,
RunInput,
RunPrompt,
RunProvider,
RunTuiConfig,
StreamCommit,
} from "./types"
/** @internal Exported for testing */
export { pickVariant, resolveVariant } from "./variant.shared"
@@ -45,9 +51,7 @@ type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promis
type RunRuntimeInput = {
boot: () => Promise<BootContext>
afterPaint?: (ctx: BootContext) => Promise<void> | void
resolveSession?: (
ctx: BootContext,
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
resolveSession?: (ctx: BootContext) => Promise<ResolvedSession>
createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
files: RunInput["files"]
initialInput?: string
@@ -56,13 +60,14 @@ type RunRuntimeInput = {
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
}
type RunLocalInput = {
type RunDeferredInput = {
sdk: RunInput["sdk"]
directory: string
fetch: typeof globalThis.fetch
resolveAgent: () => Promise<string | undefined>
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string; resume?: boolean } | undefined>
createSession?: CreateSession
agent: RunInput["agent"]
model: RunInput["model"]
@@ -74,6 +79,7 @@ type RunLocalInput = {
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
}
type StreamTransportModule = Pick<
@@ -91,10 +97,13 @@ type StreamState = {
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
}
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
type ResolvedSession = {
sessionID: string
sessionTitle?: string
agent?: string | undefined
resume?: boolean
}
function createSessionResolver(fn?: CreateSession) {
@@ -130,7 +139,7 @@ type RuntimeState = {
sessionTitle?: string
agent: string | undefined
switching?: Promise<void>
demo?: ReturnType<typeof createRunDemo>
demo?: RunDemo
selectSubagent?: (sessionID: string | undefined) => void
session?: Promise<void>
stream?: Promise<StreamState>
@@ -164,9 +173,9 @@ async function resolveExitTitle(
return undefined
}
return ctx.sdk.v2.session
return ctx.sdk.session
.get({ sessionID: state.sessionID })
.then((x) => x.data?.data.title)
.then((session) => session.title)
.catch(() => undefined)
}
@@ -179,23 +188,23 @@ async function resolveExitTitle(
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
const start = performance.now()
const log = trace()
const tuiConfigTask = resolveRunTuiConfig()
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig)
const ctx = await input.boot()
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
const sessionTask =
ctx.resume === true
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
: Promise.resolve({
first: true,
history: [],
model: undefined,
variant: undefined,
})
const savedTask = resolveSavedVariant(ctx.model)
const [tuiConfig, session, savedVariant] = await Promise.all([tuiConfigTask, sessionTask, savedTask])
const [session, savedVariant] = await Promise.all([sessionTask, savedTask])
const state: RuntimeState = {
shown: !session.first,
aborting: false,
model: ctx.model,
model: ctx.model ?? session.model,
providers: [],
variants: [],
limits: {},
@@ -206,29 +215,49 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
sessionTitle: ctx.sessionTitle,
agent: ctx.agent,
}
const ensureSession = () => {
if (!input.resolveSession || state.sessionID) {
return Promise.resolve()
const loadModel = async () => {
if (state.model) {
return {
model: state.model,
savedVariant,
boot: true,
info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model),
}
}
if (state.session) {
return state.session
}
state.session = input.resolveSession(ctx).then((next) => {
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent
const model = await waitForDefaultModel({
sdk: ctx.sdk,
directory: ctx.directory,
active: () => !footer.isClosed,
})
return state.session
}
if (footer.isClosed) return
const [fallbackSavedVariant, info] = await Promise.all([
resolveSavedVariant(model),
resolveModelInfo(ctx.sdk, ctx.directory, model),
])
if (!model || state.model) {
return {
model: state.model,
savedVariant: undefined,
boot: false,
info,
}
}
state.model = model
return {
model,
savedVariant: fallbackSavedVariant,
boot: true,
info,
}
}
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
directory: ctx.directory,
findFiles: (query) =>
ctx.sdk.find
.files({ query, directory: ctx.directory })
.then((x) => x.data ?? [])
ctx.sdk.file
.find({ query, type: "file", location: { directory: ctx.directory } })
.then((result) => result.data.map((file) => file.path))
.catch(() => []),
agents: [],
references: [],
@@ -236,11 +265,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
sessionTitle: state.sessionTitle,
getSessionID: () => state.sessionID,
first: session.first,
history: session.history,
history: state.history,
agent: state.agent,
model: state.model,
variant: state.activeVariant,
tuiConfig,
tuiConfig: tuiConfigTask,
backgroundSubagents: input.backgroundSubagents,
onPermissionReply: async (next) => {
if (state.demo?.permission(next)) {
@@ -248,17 +277,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
log?.write("send.permission.reply", next)
await ctx.sdk.v2.session.permission.reply({ sessionID: state.sessionID, ...next })
await ctx.sdk.permission.reply({ sessionID: state.sessionID, ...next })
},
onQuestionReply: async (next) => {
if (state.demo?.questionReply(next)) {
return
}
await ctx.sdk.v2.session.question.reply({
await ctx.sdk.question.reply({
sessionID: state.sessionID,
requestID: next.requestID,
questionV2Reply: { answers: next.answers ?? [] },
answers: next.answers ?? [],
})
},
onQuestionReject: async (next) => {
@@ -266,7 +295,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return
}
await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
await ctx.sdk.question.reject({ sessionID: state.sessionID, ...next })
},
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {
@@ -345,9 +374,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
state.aborting = true
void (state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }))
void (
state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: ctx.sdk.session.interrupt({ sessionID: state.sessionID })
)
.catch(() => {})
.finally(() => {
state.aborting = false
@@ -360,11 +391,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
log?.write("send.background", { sessionID: state.sessionID })
void ctx.sdk.v2.session.background({ sessionID: state.sessionID }).catch(() => {})
void ctx.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
},
onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID })
void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {})
void ctx.sdk.session.interrupt({ sessionID }).catch(() => {})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
@@ -374,49 +405,148 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
},
})
const footer = shell.footer
const firstPaint = footer.idle().catch(() => {})
const ensureSession = () => {
if (!input.resolveSession || state.sessionID) {
return Promise.resolve()
}
if (state.session) {
return state.session
}
state.session = input.resolveSession(ctx).then(async (next) => {
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent
if (!next.resume) return
const resumed = await resolveSessionInfo(ctx.sdk, next.sessionID, ctx.model)
session.first = resumed.first
session.history = resumed.history
session.model = resumed.model
session.variant = resumed.variant
state.shown = !resumed.first
state.history = [...resumed.history]
state.model = ctx.model ?? resumed.model
const resumedSavedVariant = state.model ? await resolveSavedVariant(state.model) : undefined
state.activeVariant = resolveVariant(ctx.variant, resumed.variant, resumedSavedVariant, [])
session.variant = state.activeVariant
footer.event({ type: "history", history: resumed.history })
footer.event({ type: "first", first: resumed.first })
})
return state.session
}
const modelTask = firstPaint.then(async () => {
if (footer.isClosed) return
await ensureSession()
if (footer.isClosed) return
return loadModel()
})
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
}
const loadCatalog = async (): Promise<void> => {
const applyCatalog = (catalog: {
agents: Awaited<ReturnType<typeof loadRunAgents>>
references: Awaited<ReturnType<typeof loadRunReferences>>
commands: Awaited<ReturnType<typeof loadRunCommands>>
}) => {
if (footer.isClosed) {
return
}
const [agents, references, commands] = await Promise.all([
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
])
if (footer.isClosed) {
return
}
footer.event({
type: "catalog",
agents,
references,
commands,
agents: catalog.agents,
references: catalog.references,
commands: catalog.commands,
})
}
void footer
.idle()
.then(loadCatalog)
.catch(() => {})
const fetchCatalog = async () => {
const [agents, references, commands] = await Promise.all([
loadRunAgents(ctx.sdk, ctx.directory),
loadRunReferences(ctx.sdk, ctx.directory),
loadRunCommands(ctx.sdk, ctx.directory),
])
return { agents, references, commands }
}
const loadCatalog = async () => {
applyCatalog(
await Promise.all([
loadRunAgents(ctx.sdk, ctx.directory).catch(() => []),
loadRunReferences(ctx.sdk, ctx.directory).catch(() => []),
loadRunCommands(ctx.sdk, ctx.directory).catch(() => []),
]).then(([agents, references, commands]) => ({ agents, references, commands })),
)
}
const applyModelInfo = (
info: Awaited<ReturnType<typeof resolveModelInfo>>,
current: string | undefined,
boot = false,
saved = savedVariant,
) => {
state.providers = info.providers
state.variants = variantsFor(state.providers, state.model)
state.limits = info.limits
state.activeVariant = boot
? resolveVariant(ctx.variant, current, saved, state.variants)
: current && !state.variants.includes(current)
? undefined
: current
if (footer.isClosed) return
footer.event({ type: "models", providers: info.providers })
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
if (state.model)
footer.event({
type: "model",
model: formatModelLabel(state.model, state.activeVariant, state.providers),
selection: state.model,
})
}
let catalogRefresh: Promise<void> | undefined
let catalogRefreshQueued = false
const requestCatalogRefresh = () => {
catalogRefreshQueued = true
if (catalogRefresh || footer.isClosed) return
catalogRefresh = (async () => {
await Promise.all([modelTask, initialCatalog])
while (catalogRefreshQueued && !footer.isClosed) {
catalogRefreshQueued = false
const [catalog, info] = await Promise.allSettled([
fetchCatalog(),
resolveModelInfoStrict(ctx.sdk, ctx.directory, state.model),
])
if (catalog.status === "fulfilled") applyCatalog(catalog.value)
if (info.status === "fulfilled") applyModelInfo(info.value, state.activeVariant)
}
})().finally(() => {
catalogRefresh = undefined
if (catalogRefreshQueued) requestCatalogRefresh()
})
void catalogRefresh.catch(() => {})
}
const initialCatalog = firstPaint.then(() => (footer.isClosed ? undefined : loadCatalog())).catch(() => {})
void initialCatalog
if (Flag.OPENCODE_SHOW_TTFD) {
footer.append({
kind: "system",
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
phase: "final",
source: "system",
void firstPaint.then(() => {
if (footer.isClosed) return
footer.append({
kind: "system",
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
phase: "final",
source: "system",
})
})
}
if (input.demo) {
await ensureSession()
state.demo = createRunDemo({
const createDemo = async () => {
const { createRunDemo } = await import("./demo")
return createRunDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
@@ -424,37 +554,35 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
}
if (input.afterPaint) {
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
if (input.demo) {
await firstPaint
if (!footer.isClosed) {
await ensureSession()
state.demo = await createDemo()
}
}
void modelTask.then((info) => {
state.providers = info.providers
state.variants = variantsFor(state.providers, state.model)
state.limits = info.limits
if (input.afterPaint) {
void firstPaint.then(() => (footer.isClosed ? undefined : input.afterPaint?.(ctx))).catch(() => {})
}
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
if (next !== state.activeVariant) {
state.activeVariant = next
}
if (footer.isClosed) {
return
}
footer.event({ type: "models", providers: info.providers })
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
if (!state.model) {
return
}
footer.event({
type: "model",
model: formatModelLabel(state.model, state.activeVariant, state.providers),
})
void modelTask.then((result) => {
if (!result) return
const current = state.model
const boot =
result.boot &&
!!current &&
current.providerID === result.model?.providerID &&
current.modelID === result.model.modelID
applyModelInfo(result.info, boot ? session.variant : state.activeVariant, boot, result.savedVariant)
})
const streamTask = deps.streamTransport ?? import("./stream-v2.transport")
let streamTask = deps.streamTransport
const loadStreamTransport = () => {
if (streamTask) return streamTask
streamTask = import("./stream-v2.transport")
return streamTask
}
const ensureStream = () => {
if (state.stream) {
return state.stream
@@ -468,7 +596,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
throw new Error("runtime closed")
}
const mod = await streamTask
const mod = await loadStreamTransport()
if (footer.isClosed) {
throw new Error("runtime closed")
}
@@ -484,6 +612,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
providers: () => state.providers,
footer,
trace: log,
onCatalogRefresh: requestCatalogRefresh,
})
if (footer.isClosed) {
await handle.close()
@@ -536,6 +665,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
})
const runQueue = async () => {
await firstPaint
if (footer.isClosed) return
await ensureSession()
if (footer.isClosed) return
await modelTask
if (footer.isClosed) return
let includeFiles = true
if (state.demo) {
await state.demo.start()
@@ -581,14 +716,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.history = []
state.localRows = []
includeFiles = true
state.demo = input.demo
? createRunDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
})
: undefined
state.demo = input.demo ? await createDemo() : undefined
log?.write("session.new", {
sessionID: state.sessionID,
})
@@ -631,7 +759,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
text: error instanceof Error ? error.message : String(error),
phase: "start",
source: "system",
messageID: MessageID.ascending(),
messageID: SessionMessage.ID.create(),
} as const
rememberLocal(commit)
footer.append(commit)
@@ -665,7 +793,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
)
}
includeFiles = false
// Shell and skill turns never send CLI file attachments; keep them
// pending for the next prompt-shaped turn.
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
} catch (error) {
if (signal.aborted || footer.isClosed) {
return
@@ -691,6 +821,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
try {
const eager = eagerStream(input, ctx)
if (eager) {
await firstPaint
if (footer.isClosed) return
if (input.replay && state.shown) {
// Replay commits immutable scrollback rows, so wait for provider names
// before bootstrapping existing session history.
@@ -701,13 +833,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
if (!eager && input.resolveSession) {
queueMicrotask(() => {
if (footer.isClosed) {
return
}
void firstPaint
.then(() => {
if (footer.isClosed) {
return
}
void ensureStream().catch(() => {})
})
return ensureStream()
})
.catch(() => {})
}
try {
@@ -731,61 +865,62 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
}
// Local in-process mode. Creates an SDK client backed by a direct fetch to
// the in-process server, so no external HTTP server is needed.
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: input.fetch,
directory: input.directory,
})
// Deferred mode paints before session resolution. The caller may back the
// generated client with a transport that is still acquiring a daemon.
export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise<void> {
const sdk = input.sdk
let session: Promise<ResolvedSession> | undefined
return runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
resolveSession: () => {
if (session) {
return runInteractiveRuntime(
{
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
backgroundSubagents: input.backgroundSubagents,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
resolveSession: () => {
if (session) {
return session
}
session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
if (!next?.id) {
throw new Error("Session not found")
}
return {
sessionID: next.id,
sessionTitle: next.title,
agent,
resume: next.resume,
}
})
return session
}
session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
if (!next?.id) {
throw new Error("Session not found")
}
},
createSession: createSessionResolver(input.createSession),
boot: async () => {
return {
sessionID: next.id,
sessionTitle: next.title,
agent,
sdk,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
}
})
return session
},
},
createSession: createSessionResolver(input.createSession),
boot: async () => {
return {
sdk,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
}
},
})
deps,
)
}
// Attach mode. Uses the caller-provided SDK client directly.
export async function runInteractiveMode(
input: RunInput & { createSession?: CreateSession },
input: RunInput & { createSession?: CreateSession; tuiConfig?: RunTuiConfig | Promise<RunTuiConfig> },
deps?: RunRuntimeDeps,
): Promise<void> {
return runInteractiveRuntime(
@@ -797,6 +932,7 @@ export async function runInteractiveMode(
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
boot: async () => ({
sdk: input.sdk,
directory: input.directory,
@@ -105,7 +105,7 @@ export class RunScrollbackStream {
) {
this.diffStyle = options.diffStyle
this.sessionID = options.sessionID
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
this.treeSitterClient = options.treeSitterClient
this.wrote = options.wrote ?? false
this.onThemeRelease = options.onThemeRelease
}
@@ -151,6 +151,7 @@ export class RunScrollbackStream {
startOnNewLine: entryFlags(commit).startOnNewLine,
})
const style = entryLook(commit, this.theme.entry)
const treeSitterClient = body.type === "text" ? undefined : (this.treeSitterClient ??= getTreeSitterClient())
const renderable =
body.type === "text"
? new TextRenderable(surface.renderContext, {
@@ -170,7 +171,7 @@ export class RunScrollbackStream {
drawUnstyledText: false,
streaming: true,
fg: entryColor(commit, this.theme),
treeSitterClient: this.treeSitterClient,
treeSitterClient,
})
: new MarkdownRenderable(surface.renderContext, {
content: "",
@@ -180,7 +181,7 @@ export class RunScrollbackStream {
internalBlockMode: "top-level",
tableOptions: { widthMode: "content" },
fg: entryColor(commit, this.theme),
treeSitterClient: this.treeSitterClient,
treeSitterClient,
})
surface.root.add(renderable)
@@ -25,7 +25,7 @@
// event arrives, the queue entry is removed and the footer falls back
// to the next pending request or to the prompt view.
import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import * as Locale from "@/util/locale"
import { Locale } from "@opencode-ai/tui/util/locale"
import { toolView } from "./tool"
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
@@ -62,7 +62,7 @@ type SessionCommit = StreamCommit
// - sent: part ID → byte offset of last flushed text (for incremental output)
// - visible: part ID → rendered text for an active part after display transforms
// - end: part IDs whose time.end has arrived (part is finished)
// - shell: shell call ID → chosen transcript source for direct shell calls
// - shell: shell ID → chosen transcript source for direct shell calls
// - echo: message ID → bash outputs to strip from the next assistant chunk
type ShellCall = {
source: "shell" | "tool"
@@ -607,12 +607,12 @@ function toolCommit(
}
}
function shellPartID(callID: string): string {
return `shell:${callID}`
function shellPartID(shellID: string): string {
return `shell:${shellID}`
}
function claimShell(data: SessionData, callID: string, source: ShellCall["source"], command?: string): ShellCall {
const current = data.shell.get(callID)
function claimShell(data: SessionData, shellID: string, source: ShellCall["source"], command?: string): ShellCall {
const current = data.shell.get(shellID)
if (current) {
if (command && !current.command) {
current.command = command
@@ -625,7 +625,7 @@ function claimShell(data: SessionData, callID: string, source: ShellCall["source
source,
...(command ? { command } : {}),
} satisfies ShellCall
data.shell.set(callID, next)
data.shell.set(shellID, next)
return next
}
@@ -728,37 +728,37 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
const data = input.data
const event = input.event
if (event.type === "shell.started") {
if (event.type === "session.shell.started") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
const shell = claimShell(data, event.properties.callID, "shell", event.properties.command)
const shell = claimShell(data, event.properties.shell.id, "shell", event.properties.shell.command)
if (shell.source !== "shell") {
return out(data, commits)
}
const partID = shellPartID(event.properties.callID)
const partID = shellPartID(event.properties.shell.id)
if (data.ids.has(partID) || data.tools.has(partID)) {
return out(data, commits, patch({ status: "running shell" }))
}
data.tools.add(partID)
commits.push(startShell(event.properties.callID, shell.command ?? event.properties.command))
commits.push(startShell(event.properties.shell.id, shell.command ?? event.properties.shell.command))
return out(data, commits, patch({ status: "running shell" }))
}
if (event.type === "shell.ended") {
if (event.type === "session.shell.ended") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
const shell = claimShell(data, event.properties.callID, "shell")
const shell = claimShell(data, event.properties.shell.id, "shell")
if (shell.source !== "shell") {
return out(data, commits)
}
const partID = shellPartID(event.properties.callID)
const partID = shellPartID(event.properties.shell.id)
const seen = data.tools.has(partID)
const command = shell.command ?? ""
data.tools.delete(partID)
@@ -767,11 +767,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
}
if (!seen && command) {
commits.push(startShell(event.properties.callID, command))
commits.push(startShell(event.properties.shell.id, command))
}
data.ids.add(partID)
commits.push(doneShell(event.properties.callID, command, event.properties.output))
commits.push(doneShell(event.properties.shell.id, command, event.properties.output.output))
return out(data, commits)
}
@@ -4,11 +4,12 @@
// the prompt history ring. Also finds the most recently used variant for
// the current model so the footer can pre-select it.
import { promptCopy, promptSame } from "./prompt.shared"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200
export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
export type SessionMessages = Array<{ info: Message; parts: Part[] }>
type Turn = {
prompt: RunPrompt
@@ -20,6 +21,8 @@ type Turn = {
export type RunSession = {
first: boolean
turns: Turn[]
model?: NonNullable<RunInput["model"]>
variant?: string
}
function fileName(url: string, filename?: string) {
@@ -157,9 +160,11 @@ export async function resolveCurrentSession(
sessionID: string,
limit = LIMIT,
): Promise<RunSession> {
const response = await sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true })
const messages = response.data.data.toReversed()
const session = await sdk.v2.session.get({ sessionID }, { throwOnError: true })
const [response, session] = await Promise.all([
sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.session.get({ sessionID }),
])
const messages = response.data.toReversed()
return {
first: messages.length === 0,
turns: messages.flatMap((message) => {
@@ -191,12 +196,19 @@ export async function resolveCurrentSession(
})),
],
},
provider: session.data.data.model?.providerID,
model: session.data.data.model?.id,
variant: session.data.data.model?.variant,
provider: session.model?.providerID,
model: session.model?.id,
variant: session.model?.variant,
},
]
}),
...(session.model && {
model: {
providerID: session.model.providerID,
modelID: session.model.id,
},
variant: session.model.variant,
}),
}
}
@@ -223,6 +235,10 @@ export function sessionVariant(session: RunSession, model: RunInput["model"]): s
return undefined
}
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) {
return session.variant
}
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
const turn = session.turns[idx]
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
@@ -17,8 +17,8 @@ import {
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import * as Locale from "@/util/locale"
import { go } from "@/cli/logo"
import { Locale } from "@opencode-ai/tui/util/locale"
import { go } from "@opencode-ai/tui/logo"
import type { RunSplashTheme } from "./theme"
export const SPLASH_TITLE_LIMIT = 50
@@ -15,22 +15,19 @@
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists.
import type {
OpencodeClient,
SessionMessage,
SessionMessageAssistantTool,
ToolPart,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
const CHILD_MESSAGE_LIMIT = 80
const CHILD_FRAME_LIMIT = 80
const DISCOVERY_BUFFER_LIMIT = 64
const CHILD_EVENT_BUFFER_LIMIT = 64
const FAMILY_LIST_LIMIT = 100
const FALLBACK_LABEL = "Subagent"
type V2Event = EventSubscribeOutput
export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) {
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
}
@@ -160,11 +157,12 @@ type ChildState = {
tools: Map<string, ToolTrack>
finishedTools: Set<string>
messageIDs: Set<string>
prompts: Map<string, string>
hydrated: boolean
}
export type SubagentTrackerInput = {
sdk: OpencodeClient
sdk: OpenCodeClient
sessionID: string
thinking: boolean
emit: () => void
@@ -223,6 +221,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
// Foreign events buffered while a session.get discovery is in flight, so a
// fast child (including its settled event) is not lost mid-discovery.
const pendingEvents = new Map<string, V2Event[]>()
const hydrationEvents = new Map<string, V2Event[]>()
const hydrationOverflow = new Set<string>()
const hydrations = new Map<string, Promise<void>>()
let selected: string | undefined
@@ -244,6 +244,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
tools: new Map(),
finishedTools: new Set(),
messageIDs: new Set(),
prompts: new Map(),
hydrated: false,
}
if (!existing) children.set(sessionID, child)
@@ -332,6 +333,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
child.callIDs.clear()
for (const message of messages) {
if (message.type === "user") {
child.prompts.delete(message.id)
userFrame(child, message.id, message.text)
continue
}
@@ -382,16 +384,38 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const hydrateChild = (child: ChildState): Promise<void> => {
const existing = hydrations.get(child.sessionID)
if (existing) return existing
const task = input.sdk.v2.session
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
const pendingPrompts = new Map(child.prompts)
const pendingTools = new Map(child.tools)
let retry = false
const task = input.sdk.message
.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" })
.then((response) => {
rebuild(child, response.data.data.toReversed())
const buffered = hydrationEvents.get(child.sessionID) ?? []
hydrationEvents.delete(child.sessionID)
if (hydrationOverflow.delete(child.sessionID)) {
child.hydrated = false
retry = true
notifyDetail(child)
return
}
for (const [id, prompt] of pendingPrompts) {
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
}
rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[])
for (const [id, tool] of pendingTools) {
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
}
for (const event of buffered) reduce(child, event)
child.hydrated = true
notifyDetail(child)
})
.catch(() => {})
.catch(() => {
hydrationEvents.delete(child.sessionID)
hydrationOverflow.delete(child.sessionID)
})
.finally(() => {
hydrations.delete(child.sessionID)
if (retry) queueMicrotask(() => void hydrateChild(child))
})
hydrations.set(child.sessionID, task)
return task
@@ -401,10 +425,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return
checked.add(sessionID)
if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
void input.sdk.v2.session
.get({ sessionID }, { throwOnError: true })
.then((response) => {
const session = response.data.data
void input.sdk.session
.get({ sessionID })
.then((session) => {
const buffered = pendingEvents.get(sessionID) ?? []
pendingEvents.delete(sessionID)
if (session.parentID !== input.sessionID) return
@@ -424,21 +447,27 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
const reduce = (child: ChildState, event: V2Event) => {
if (event.type === "prompt.promoted") {
if (userFrame(child, event.data.inputID, "")) {
if (event.type === "session.prompt.admitted") {
child.prompts.set(event.data.inputID, event.data.prompt.text)
return
}
if (event.type === "session.prompt.promoted") {
const prompt = child.prompts.get(event.data.inputID) ?? ""
child.prompts.delete(event.data.inputID)
if (userFrame(child, event.data.inputID, prompt)) {
touch(child, event.created)
notifyDetail(child)
}
return
}
if (event.type === "step.started") {
if (event.type === "session.step.started") {
touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
if (child.status !== "running") child.status = "running"
input.emit()
return
}
if (event.type === "text.delta") {
if (event.type === "session.text.delta") {
const projected = child.projectedText.get(event.data.textID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -459,7 +488,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "text.ended") {
if (event.type === "session.text.ended") {
child.text.set(event.data.textID, event.data.text)
child.projectedText.delete(event.data.textID)
setFrame(child, `text:${event.data.textID}`, {
@@ -474,7 +503,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "reasoning.delta") {
if (event.type === "session.reasoning.delta") {
const projected = child.projectedReasoning.get(event.data.reasoningID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -495,7 +524,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "reasoning.ended") {
if (event.type === "session.reasoning.ended") {
child.reasoning.set(event.data.reasoningID, event.data.text)
child.projectedReasoning.delete(event.data.reasoningID)
if (!input.thinking) return
@@ -510,11 +539,13 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "tool.input.started") {
if (event.type === "session.tool.input.started") {
if (child.finishedTools.has(event.data.callID)) return
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created })
return
}
if (event.type === "tool.called") {
if (event.type === "session.tool.called") {
if (child.finishedTools.has(event.data.callID)) return
const current = child.tools.get(event.data.callID)
child.tools.set(event.data.callID, {
name: event.data.tool,
@@ -523,27 +554,27 @@ 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)
notifyDetail(child)
return
}
if (event.type === "tool.success" || event.type === "tool.failed") {
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
if (child.finishedTools.has(event.data.callID)) return
const current = child.tools.get(event.data.callID)
const failed = event.type === "tool.failed"
const failed = event.type === "session.tool.failed"
childTool(
child,
{
structuredClone({
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
@@ -570,14 +601,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
ran: current?.started,
completed: event.created,
},
},
}) as SessionMessageAssistantTool,
event.data.assistantMessageID,
)
touch(child, event.created)
notifyDetail(child)
return
}
if (event.type === "step.failed") {
if (event.type === "session.step.failed") {
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
kind: "error",
source: "system",
@@ -589,7 +620,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "execution.settled") {
if (event.type === "session.execution.settled") {
child.status =
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
touch(child, event.created)
@@ -613,15 +644,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return {
main(event) {
if (event.type === "tool.called") {
if (event.type === "session.tool.called") {
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
return
}
if (event.type === "tool.failed") {
if (event.type === "session.tool.failed") {
pendingCalls.delete(event.data.callID)
return
}
if (event.type !== "tool.success") return
if (event.type !== "session.tool.success") return
const pending = pendingCalls.get(event.data.callID)
pendingCalls.delete(event.data.callID)
const found = childSessionID(record(event.data.structured))
@@ -640,12 +671,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
foreign(sessionID, event) {
const child = children.get(sessionID)
if (child) {
if (hydrations.has(sessionID)) {
const buffered = hydrationEvents.get(sessionID) ?? []
if (buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event)
else hydrationOverflow.add(sessionID)
hydrationEvents.set(sessionID, buffered)
}
reduce(child, event)
return
}
discover(sessionID)
const buffered = pendingEvents.get(sessionID)
if (buffered && buffered.length < DISCOVERY_BUFFER_LIMIT) buffered.push(event)
if (buffered && buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event)
},
async hydrate(next) {
for (const message of next.messages) {
@@ -656,9 +693,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
// Family index: adopt children directly from the current session list so
// historical subagents beyond the projected message window still get tabs.
const family = await input.sdk.v2.session
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
.then((response) => response.data.data)
const family = await input.sdk.session
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" })
.then((response) => response.data)
.catch(() => [])
for (const session of family) {
const child = ensureChild(session.id)
@@ -1,14 +1,15 @@
import type {
OpencodeClient,
EventSubscribeOutput,
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type {
PermissionRequest,
PermissionV2Request,
QuestionRequest,
QuestionV2Request,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantTool,
V2Event,
} from "@opencode-ai/sdk/v2"
import { Event } from "@opencode-ai/schema/event"
import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream"
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
@@ -30,7 +31,7 @@ type Trace = {
}
type StreamInput = {
sdk: OpencodeClient
sdk: OpenCodeClient
directory?: string
sessionID: string
thinking: boolean
@@ -41,6 +42,7 @@ type StreamInput = {
footer: FooterApi
trace?: Trace
signal?: AbortSignal
onCatalogRefresh?: () => void
}
export type SessionTurnInput = {
@@ -77,7 +79,20 @@ type Wait = {
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
}
type RunV2Event = V2Event
// One active session.shell call. The HTTP response is the completion signal;
// callID correlates the live shell events once shell.started is observed, and
// abort cancels the blocking request when the user interrupts the turn.
type ShellWait = {
eventID: string
messageID: string
callID?: string
resolve: () => void
abort: () => void
}
type RunV2Event = EventSubscribeOutput
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = {
@@ -99,6 +114,11 @@ type State = {
projectedReasoning: Map<string, string>
tools: Map<string, ToolState>
finishedTools: Set<string>
skillMessages: Set<string>
shellCommands: Map<string, string>
shellStarted: Set<string>
shellEnded: Set<string>
shellWait?: ShellWait
wait?: Wait
connected: boolean
closed: boolean
@@ -126,9 +146,9 @@ function permission(request: PermissionV2Request): PermissionRequest {
id: request.id,
sessionID: request.sessionID,
permission: request.action,
patterns: request.resources,
patterns: [...request.resources],
metadata: request.metadata ?? {},
always: request.save ?? [],
always: [...(request.save ?? [])],
tool: request.source?.type === "tool" ? request.source : undefined,
}
}
@@ -137,7 +157,7 @@ function question(request: QuestionV2Request): QuestionRequest {
return {
id: request.id,
sessionID: request.sessionID,
questions: request.questions,
questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
tool: request.tool,
}
}
@@ -179,22 +199,120 @@ function promptFileSource(part: PromptFilePart) {
}
}
function promptFiles(next: SessionTurnInput) {
return next.prompt.parts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: promptFileSource(part),
},
]
: [],
)
}
function promptAgents(next: SessionTurnInput) {
return next.prompt.parts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
)
}
function streamPartKey(messageID: string, partID: string) {
return `${messageID}\u0000${partID}`
}
// Matches the commit shapes the legacy session-data reducer produced for direct
// shell calls: one "start" commit rendering `$ command` and one "progress"
// commit rendering the merged output (see toolEntryBody in tool.ts).
function shellCommit(
callID: string,
command: string,
next: Pick<StreamCommit, "text" | "phase" | "toolState" | "toolError">,
): StreamCommit {
return {
kind: "tool",
source: "tool",
partID: `shell:${callID}`,
tool: "bash",
shell: { callID, command },
...next,
}
}
function shellTerminal(
callID: string,
command: string,
shell: { status: string; exit?: number | string },
output: { output: string; cursor: number; size: number; truncated: boolean },
) {
const incomplete = output.truncated || output.cursor < output.size
const text = `${output.output}${incomplete ? `${output.output.endsWith("\n") || !output.output ? "" : "\n"}[output truncated]` : ""}`
const error =
shell.status === "exited" && shell.exit === 0
? undefined
: shell.status === "exited"
? `Shell exited with code ${shell.exit ?? "unknown"}`
: `Shell ${shell.status}`
if (!error)
return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
return [
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
]
}
function messageIDFromEvent(id: string) {
return id.replace(/^evt_/, "msg_")
}
const catalogEvents = new Set([
"catalog.updated",
"integration.updated",
"agent.updated",
"command.updated",
"skill.updated",
"reference.updated",
])
// session.shell resolves after the command settled server-side; the matching
// live shell.ended event usually lands within the same tick, but hold the turn
// briefly so the output commit renders inside it.
const SHELL_OUTPUT_GRACE_MS = 1500
function skillCommit(messageID: string, name: string): StreamCommit {
return {
kind: "system",
source: "system",
messageID,
partID: `skill:${messageID}`,
text: `→ Skill "${name}"`,
phase: "start",
}
}
async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnInput, "model" | "variant" | "signal">) {
if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
if (!next.variant) return
const session = await input.sdk.v2.session
.get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal })
.then((response) => response.data.data.model)
const session = await input.sdk.session
.get({ sessionID: input.sessionID }, { signal: next.signal })
.then((response) => response.model)
if (session) return { ...session, variant: next.variant }
const fallback = await input.sdk.v2.model
.default(undefined, { throwOnError: true, signal: next.signal })
.then((response) => response.data.data)
const fallback = await input.sdk.model
.default(undefined, { signal: next.signal })
.then((response) => response.data)
if (!fallback) return
return { providerID: fallback.providerID, id: fallback.id, variant: next.variant }
}
@@ -213,6 +331,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
projectedReasoning: new Map(),
tools: new Map(),
finishedTools: new Set(),
skillMessages: new Set(),
shellCommands: new Map(),
shellStarted: new Set(),
shellEnded: new Set(),
connected: false,
closed: false,
initial: true,
@@ -314,6 +436,47 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }])
return
}
if (message.type === "skill") {
if (state.wait?.messageID === message.id) state.wait.promoted = true
if (!render || state.skillMessages.has(message.id)) {
state.skillMessages.add(message.id)
return
}
state.skillMessages.add(message.id)
write([skillCommit(message.id, message.name)])
return
}
if (message.type === "shell") {
state.shellCommands.set(message.shell.id, message.shell.command)
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shell.id
const completed = message.time.completed !== undefined
if (!render) {
// Suppressed history: mark settled shells rendered so live redelivery
// stays silent. A still-running shell stays unmarked and renders in
// full when its live shell.ended event arrives.
if (completed) {
state.shellStarted.add(message.shell.id)
state.shellEnded.add(message.shell.id)
}
return
}
if (!state.shellStarted.has(message.shell.id)) {
state.shellStarted.add(message.shell.id)
write([
shellCommit(message.shell.id, message.shell.command, {
text: "running shell",
phase: "start",
toolState: "running",
}),
])
}
if (completed && message.output && !state.shellEnded.has(message.shell.id)) {
state.shellEnded.add(message.shell.id)
write(shellTerminal(message.shell.id, message.shell.command, message.shell, message.output))
}
if (completed && state.shellWait?.callID === message.shell.id) state.shellWait.resolve()
return
}
if (message.type !== "assistant") return
state.messageIDs.add(message.id)
for (const item of message.content) {
@@ -371,21 +534,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => {
const [messages, permissions, questions, active] = await Promise.all([
input.sdk.v2.session.messages(
{ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" },
{ throwOnError: true },
),
input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }),
input.sdk.v2.session.active({ throwOnError: true }),
input.sdk.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }),
input.sdk.permission.list({ sessionID: input.sessionID }),
input.sdk.question.list({ sessionID: input.sessionID }),
input.sdk.session.active(),
])
const projected = messages.data.data.toReversed()
const projected = structuredClone(messages.data).toReversed() as SessionMessage[]
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.data.data.map(permission)
state.questions = questions.data.data.map(question)
state.permissions = permissions.map(permission)
state.questions = questions.map(question)
syncBlockers()
await subagents.hydrate({ messages: projected, active: active.data.data })
const running = input.sessionID in active.data.data
await subagents.hydrate({ messages: [...projected], active: active.data })
const running = input.sessionID in active.data
write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" })
if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) {
const current = state.wait
@@ -395,6 +555,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
const apply = (event: RunV2Event) => {
if (catalogEvents.has(event.type)) {
if (input.directory && event.location?.directory && event.location.directory !== input.directory) return
input.onCatalogRefresh?.()
return
}
const source = sessionID(event)
if (source !== input.sessionID) {
if (source) subagents.foreign(source, event)
@@ -402,17 +567,66 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
input.trace?.write("recv.event", event)
subagents.main(event)
if (event.type === "prompt.promoted") {
if (event.type === "session.prompt.promoted") {
if (state.wait?.messageID === event.data.inputID) state.wait.promoted = true
state.messageIDs.add(event.data.inputID)
write([], { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "step.started") {
if (event.type === "session.step.started") {
write([], { phase: "running", status: "assistant responding" })
return
}
if (event.type === "text.delta") {
if (event.type === "session.skill.activated") {
const messageID = messageIDFromEvent(event.id)
if (state.wait?.messageID === messageID) state.wait.promoted = true
if (state.skillMessages.has(messageID)) return
state.skillMessages.add(messageID)
write([skillCommit(messageID, event.data.name)])
return
}
if (event.type === "session.shell.started") {
state.shellCommands.set(event.data.shell.id, event.data.shell.command)
const wait = state.shellWait
if (wait?.eventID === event.id) wait.callID = event.data.shell.id
if (state.shellStarted.has(event.data.shell.id)) return
state.shellStarted.add(event.data.shell.id)
write(
[
shellCommit(event.data.shell.id, event.data.shell.command, {
text: "running shell",
phase: "start",
toolState: "running",
}),
],
{
phase: "running",
status: "running shell",
},
)
return
}
if (event.type === "session.shell.ended") {
const command = state.shellCommands.get(event.data.shell.id) ?? event.data.shell.command
const commits: StreamCommit[] = []
if (!state.shellStarted.has(event.data.shell.id)) {
state.shellStarted.add(event.data.shell.id)
if (command)
commits.push(
shellCommit(event.data.shell.id, command, { text: "running shell", phase: "start", toolState: "running" }),
)
}
if (!state.shellEnded.has(event.data.shell.id)) {
state.shellEnded.add(event.data.shell.id)
commits.push(...shellTerminal(event.data.shell.id, command, event.data.shell, event.data.output))
}
const wait = state.shellWait
const owned = wait?.callID === event.data.shell.id
write(commits, owned || state.wait || state.shellWait ? undefined : { phase: "idle", status: "" })
if (owned) wait.resolve()
return
}
if (event.type === "session.text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
@@ -434,7 +648,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
return
}
if (event.type === "text.ended") {
if (event.type === "session.text.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length)
@@ -452,7 +666,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.projectedText.delete(key)
return
}
if (event.type === "reasoning.delta") {
if (event.type === "session.reasoning.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
@@ -475,7 +689,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
return
}
if (event.type === "reasoning.ended") {
if (event.type === "session.reasoning.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const previous = state.reasoning.get(key) ?? ""
if (input.thinking && event.data.text.length > previous.length)
@@ -493,7 +707,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.projectedReasoning.delete(key)
return
}
if (event.type === "tool.input.started") {
if (event.type === "session.tool.input.started") {
state.tools.set(event.data.callID, {
messageID: event.data.assistantMessageID,
name: event.data.name,
@@ -503,25 +717,25 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
})
return
}
if (event.type === "tool.called") {
if (event.type === "session.tool.called") {
if (state.finishedTools.has(event.data.callID)) return
const current = state.tools.get(event.data.callID)
const item: SessionMessageAssistantTool = {
const item = structuredClone({
type: "tool",
id: event.data.callID,
name: event.data.tool,
provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created },
}
}) as SessionMessageAssistantTool
renderTool(event.data.assistantMessageID, item)
return
}
if (event.type === "tool.progress") return
if (event.type === "tool.success" || event.type === "tool.failed") {
if (event.type === "session.tool.progress") return
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
const current = state.tools.get(event.data.callID)
const failed = event.type === "tool.failed"
const item: SessionMessageAssistantTool = {
const failed = event.type === "session.tool.failed"
const item = structuredClone({
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
@@ -544,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
}
@@ -568,7 +782,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
syncBlockers()
return
}
if (event.type === "step.ended") {
if (event.type === "session.step.ended") {
const total =
event.data.tokens.input +
event.data.tokens.output +
@@ -582,13 +796,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
})
return
}
if (event.type === "step.failed") {
if (event.type === "session.step.failed") {
state.errors.add(event.data.assistantMessageID)
if (state.wait) state.wait.failureRendered = true
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
return
}
if (event.type === "execution.settled") {
if (event.type === "session.execution.settled") {
write([], { phase: "idle", status: "" })
const current = state.wait
if (!current || (!current.promoted && !current.interrupted)) return
@@ -623,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")
@@ -644,6 +853,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
})()
void consume.catch(() => {})
await hydrate({ render: state.initial ? input.replay === true : true, reuseVisibleWait: !state.initial })
input.onCatalogRefresh?.()
state.initial = false
booting = false
for (const event of buffered.splice(0)) apply(event)
@@ -673,102 +883,185 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
controller.signal.removeEventListener("abort", abortReady)
}
const runShellTurn = async (next: SessionTurnInput) => {
if (state.wait || state.shellWait) throw new Error("prompt already running")
if (!state.connected) throw new Error("Event stream is reconnecting")
const abort = new AbortController()
const onAbort = () => abort.abort()
next.signal?.addEventListener("abort", onAbort, { once: true })
let rendered!: () => void
const output = new Promise<void>((resolve) => {
rendered = resolve
})
const eventID = Event.ID.create()
const active: ShellWait = {
eventID,
messageID: messageIDFromEvent(eventID),
resolve: rendered,
abort: () => abort.abort(),
}
state.shellWait = active
input.trace?.write("send.shell", { sessionID: input.sessionID, id: eventID, command: next.prompt.text })
write([], { phase: "running", status: "running shell" })
try {
await input.sdk.session.shell(
{ sessionID: input.sessionID, id: eventID, command: next.prompt.text },
{ signal: abort.signal },
)
await Promise.race([output, wait(SHELL_OUTPUT_GRACE_MS, abort.signal)])
} catch (error) {
if (abort.signal.aborted) return
throw error
} finally {
next.signal?.removeEventListener("abort", onAbort)
if (state.shellWait === active) state.shellWait = undefined
}
}
// Shared settlement scaffolding for prompt-shaped turns: registers the wait,
// wires interruption, sends, then blocks until the live settled event (or a
// hydration pass over an idle session) resolves it.
const runTurnWait = async (
next: SessionTurnInput,
messageID: string,
turn: { promoted?: boolean; send: () => Promise<unknown> },
) => {
let resolve!: () => void
let reject!: (error: unknown) => void
const done = new Promise<void>((ok, fail) => {
resolve = ok
reject = fail
})
const active: Wait = {
messageID,
promoted: turn.promoted === true,
interrupted: false,
failureRendered: false,
resolve,
reject,
onVisibleOutput: next.onVisibleOutput,
}
state.wait = active
const interrupt = () => {
active.interrupted = true
void input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
await turn.send()
await done
} catch (error) {
if (state.wait === active) state.wait = undefined
if (next.signal?.aborted) return
throw error
} finally {
next.signal?.removeEventListener("abort", interrupt)
}
}
return {
async runPromptTurn(next) {
if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts")
if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts")
if (state.wait) throw new Error("prompt already running")
if (next.prompt.mode === "shell") {
await runShellTurn(next)
return
}
if (state.wait || state.shellWait) throw new Error("prompt already running")
if (!state.connected) throw new Error("Event stream is reconnecting")
const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required")
const command = next.prompt.command
if (command?.source === "skill") {
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.session.skill(
{ sessionID: input.sessionID, id: messageID, skill: command.name },
{ signal: next.signal },
),
})
return
}
if (command) {
const selected = await resolveSelectedModel(input, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
// Agent and model ride the command payload; the server switches only
// when the command itself does not pin them.
const files = [
...(next.includeFiles ? next.files : []).map((file) => ({ uri: file.url, name: file.filename })),
...promptFiles(next),
]
const agents = promptAgents(next)
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.session.command(
{
sessionID: input.sessionID,
id: messageID,
command: command.name,
arguments: command.arguments,
agent: next.agent,
model: selected,
files: files.length ? files : undefined,
agents: agents.length ? agents : undefined,
delivery: "steer",
},
{ signal: next.signal },
),
})
return
}
if (next.agent) {
await input.sdk.v2.session.switchAgent(
await input.sdk.session.switchAgent(
{ sessionID: input.sessionID, agent: next.agent },
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
)
}
const selected = await resolveSelectedModel(input, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await input.sdk.v2.session.switchModel(
await input.sdk.session.switchModel(
{ sessionID: input.sessionID, model: selected },
{ throwOnError: true, signal: next.signal },
{ signal: next.signal },
)
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
const promptFiles = next.prompt.parts.flatMap((part) =>
part.type === "file"
? [
{
uri: part.url,
name: part.filename,
source: promptFileSource(part),
const attachments = [
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
...promptFiles(next),
]
const agents = promptAgents(next)
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
await runTurnWait(next, messageID, {
send: () =>
input.sdk.session.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
files: attachments.length ? attachments : undefined,
agents: agents.length ? agents : undefined,
},
]
: [],
)
const attachments = [...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), ...promptFiles]
const agents = next.prompt.parts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
source: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
)
const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required")
let resolve!: () => void
let reject!: (error: unknown) => void
const done = new Promise<void>((done, fail) => {
resolve = done
reject = fail
})
const active: Wait = {
messageID,
promoted: false,
interrupted: false,
failureRendered: false,
resolve,
reject,
onVisibleOutput: next.onVisibleOutput,
}
state.wait = active
const interrupt = () => {
active.interrupted = true
void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
await input.sdk.v2.session.prompt(
{
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
files: attachments.length ? attachments : undefined,
agents: agents.length ? agents : undefined,
delivery: "steer",
},
delivery: "steer",
},
{ throwOnError: true, signal: next.signal },
)
await done
} catch (error) {
if (state.wait === active) state.wait = undefined
if (next.signal?.aborted) return
throw error
} finally {
next.signal?.removeEventListener("abort", interrupt)
}
{ signal: next.signal },
),
})
},
async interruptActiveTurn() {
// A running shell holds no drain, so session.interrupt cannot reach it;
// abort the blocking request instead. The server-side command keeps its
// own lifecycle and simply loses its waiter.
const shell = state.shellWait
if (shell) {
shell.abort()
return
}
if (state.wait) state.wait.interrupted = true
await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
await input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
},
selectSubagent(sessionID) {
subagents.select(sessionID)
@@ -787,6 +1080,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.projectedReasoning.clear()
state.tools.clear()
state.finishedTools.clear()
state.skillMessages.clear()
state.shellCommands.clear()
state.shellStarted.clear()
state.shellEnded.clear()
state.errors.clear()
await hydrate({ render: true, reuseVisibleWait: false })
} finally {
@@ -16,25 +16,8 @@ import os from "os"
import path from "path"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import type * as Tool from "@/tool/tool"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { ShellTool as BashTool } from "@/tool/shell"
import type { EditTool } from "@/tool/edit"
import type { GlobTool } from "@/tool/glob"
import type { GrepTool } from "@/tool/grep"
import type { InvalidTool } from "@/tool/invalid"
import type { LspTool } from "@/tool/lsp"
import type { PlanExitTool } from "@/tool/plan"
import type { QuestionTool } from "@/tool/question"
import type { ReadTool } from "@/tool/read"
import type { SkillTool } from "@/tool/skill"
import type { TaskTool } from "@/tool/task"
import type { TodoWriteTool } from "@/tool/todo"
import type { WebFetchTool } from "@/tool/webfetch"
import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch"
import type { WriteTool } from "@/tool/write"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import * as Locale from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
export type ToolView = {
@@ -47,6 +30,46 @@ export type ToolPhase = "start" | "progress" | "final"
export type ToolDict = Record<string, unknown>
type PatchFile = {
type?: string
relativePath?: string
filePath?: string
movePath?: string
patch?: string
deletions?: number
}
type ToolInput = ToolDict & {
path?: string
pattern?: string
filePath?: string
filepath?: string
url?: string
query?: string
subagent_type?: string
description?: string
name?: string
operation?: string
line?: number
character?: number
content?: string
command?: string
workdir?: string
todos?: Array<{ status?: string; content?: string }>
questions?: Array<{ question?: string }>
diff?: string
}
type ToolMetadata = ToolDict & {
count?: number
matches?: number
diff?: string
provider?: unknown
files?: PatchFile[]
answers?: string[][]
exit?: number
}
export type ToolFrame = {
raw: string
name: string
@@ -73,15 +96,15 @@ export type ToolPermissionInfo = {
file?: string
}
export type ToolProps<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
export type ToolProps = {
input: ToolInput
metadata: ToolMetadata
frame: ToolFrame
}
type ToolPermissionProps<T = Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
type ToolPermissionProps = {
input: ToolInput
metadata: ToolMetadata
patterns: string[]
}
@@ -91,40 +114,35 @@ type ToolPermissionCtx = {
patterns: string[]
}
type ToolDefs = {
invalid: typeof InvalidTool
bash: typeof BashTool
write: typeof WriteTool
edit: typeof EditTool
apply_patch: typeof ApplyPatchTool
batch: Tool.Info
task: typeof TaskTool
todowrite: typeof TodoWriteTool
question: typeof QuestionTool
read: typeof ReadTool
glob: typeof GlobTool
grep: typeof GrepTool
list: Tool.Info
lsp: typeof LspTool
webfetch: typeof WebFetchTool
websearch: typeof WebSearchTool
skill: typeof SkillTool
plan_exit: typeof PlanExitTool
}
type ToolName =
| "invalid"
| "bash"
| "write"
| "edit"
| "apply_patch"
| "batch"
| "task"
| "todowrite"
| "question"
| "read"
| "glob"
| "grep"
| "list"
| "lsp"
| "webfetch"
| "websearch"
| "skill"
| "plan_exit"
type ToolName = keyof ToolDefs
type ToolRule<T = Tool.Info> = {
type ToolRule = {
view: ToolView
run: (props: ToolProps<T>) => ToolInline
scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
permission?: (props: ToolPermissionProps<T>) => ToolPermissionInfo
snap?: (props: ToolProps<T>) => ToolSnapshot | undefined
run: (props: ToolProps) => ToolInline
scroll?: Partial<Record<ToolPhase, (props: ToolProps) => string>>
permission?: (props: ToolPermissionProps) => ToolPermissionInfo
snap?: (props: ToolProps) => ToolSnapshot | undefined
}
type ToolRegistry = {
[K in ToolName]: ToolRule<ToolDefs[K]>
}
type ToolRegistry = Record<ToolName, ToolRule>
type AnyToolRule = ToolRule
@@ -136,22 +154,28 @@ function dict(v: unknown): ToolDict {
return { ...v }
}
function props<T = Tool.Info>(frame: ToolFrame): ToolProps<T> {
function props(frame: ToolFrame): ToolProps {
return {
input: Object.assign(Object.create(null), frame.input),
metadata: Object.assign(Object.create(null), frame.meta),
input: frame.input,
metadata: frame.meta,
frame,
}
}
function permission<T = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
return {
input: Object.assign(Object.create(null), ctx.input),
metadata: Object.assign(Object.create(null), ctx.meta),
input: ctx.input,
metadata: ctx.meta,
patterns: ctx.patterns,
}
}
function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
return "Web Search"
}
function text(v: unknown): string {
return typeof v === "string" ? v : ""
}
@@ -285,7 +309,7 @@ function count(n: number, label: string): string {
return `${n} ${label}${n === 1 ? "" : "es"}`
}
function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
function runGlob(p: ToolProps): ToolInline {
const root = p.input.path ?? ""
const title = `Glob "${p.input.pattern ?? ""}"`
const suffix = root ? `in ${toolPath(root)}` : ""
@@ -298,7 +322,7 @@ function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
}
}
function runGrep(p: ToolProps<typeof GrepTool>): ToolInline {
function runGrep(p: ToolProps): ToolInline {
const root = p.input.path ?? ""
const title = `Grep "${p.input.pattern ?? ""}"`
const suffix = root ? `in ${toolPath(root)}` : ""
@@ -319,7 +343,7 @@ function runList(p: ToolProps): ToolInline {
}
}
function runRead(p: ToolProps<typeof ReadTool>): ToolInline {
function runRead(p: ToolProps): ToolInline {
const file = toolPath(p.input.filePath)
const description = info(p.frame.input, ["filePath"]) || undefined
return {
@@ -329,7 +353,7 @@ function runRead(p: ToolProps<typeof ReadTool>): ToolInline {
}
}
function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
function runWrite(p: ToolProps): ToolInline {
return {
icon: "←",
title: `Write ${toolPath(p.input.filePath)}`,
@@ -338,7 +362,7 @@ function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
}
}
function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
function runWebfetch(p: ToolProps): ToolInline {
const url = p.input.url ?? ""
return {
icon: "%",
@@ -346,7 +370,7 @@ function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
}
}
function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
function runEdit(p: ToolProps): ToolInline {
return {
icon: "←",
title: `Edit ${toolPath(p.input.filePath)}`,
@@ -355,7 +379,7 @@ function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
}
}
function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
function runWebSearch(p: ToolProps): ToolInline {
const title = webSearchProviderLabel(p.metadata.provider)
return {
icon: "◈",
@@ -363,7 +387,7 @@ function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
}
}
function runTask(p: ToolProps<typeof TaskTool>): ToolInline {
function runTask(p: ToolProps): ToolInline {
const kind = Locale.titlecase(p.input.subagent_type || "unknown")
const desc = p.input.description
const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
@@ -374,7 +398,7 @@ function runTask(p: ToolProps<typeof TaskTool>): ToolInline {
}
}
function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
function runTodo(p: ToolProps): ToolInline {
return {
icon: "#",
title: "Todos",
@@ -393,14 +417,14 @@ function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
}
}
function runSkill(p: ToolProps<typeof SkillTool>): ToolInline {
function runSkill(p: ToolProps): ToolInline {
return {
icon: "→",
title: `Skill "${p.input.name ?? ""}"`,
}
}
function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
function runPatch(p: ToolProps): ToolInline {
const files = p.metadata.files?.length ?? 0
if (files === 0) {
return {
@@ -415,7 +439,7 @@ function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
}
}
function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
function runQuestion(p: ToolProps): ToolInline {
const total = list(p.frame.input.questions).length
return {
icon: "→",
@@ -423,7 +447,7 @@ function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
}
}
function runInvalid(p: ToolProps<typeof InvalidTool>): ToolInline {
function runInvalid(p: ToolProps): ToolInline {
return {
icon: "✗",
title: text(p.frame.state.title) || "Invalid Tool",
@@ -463,14 +487,14 @@ function lspTitle(
return `LSP ${op} ${file}${pos}`
}
function runLsp(p: ToolProps<typeof LspTool>): ToolInline {
function runLsp(p: ToolProps): ToolInline {
return {
icon: "→",
title: text(p.frame.state.title) || lspTitle(p.input),
}
}
function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
function runPlanExit(p: ToolProps): ToolInline {
return {
icon: "→",
title: text(p.frame.state.title) || "Switching to build agent",
@@ -479,8 +503,6 @@ function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
}
}
type PatchFile = Tool.InferMetadata<typeof ApplyPatchTool>["files"][number]
function patchTitle(file: PatchFile): string {
const rel = file.relativePath
const from = file.filePath
@@ -497,7 +519,7 @@ function patchTitle(file: PatchFile): string {
return `# Patched ${rel || toolPath(from)}`
}
function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined {
function snapWrite(p: ToolProps): ToolSnapshot | undefined {
const file = p.input.filePath || ""
const content = p.input.content || ""
if (!file && !content) {
@@ -512,7 +534,7 @@ function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined {
}
}
function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
function snapEdit(p: ToolProps): ToolSnapshot | undefined {
const file = p.input.filePath || ""
const diff = p.metadata.diff || ""
if (!file || !diff.trim()) {
@@ -531,7 +553,7 @@ function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
}
}
function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefined {
function snapPatch(p: ToolProps): ToolSnapshot | undefined {
const files = list<PatchFile>(p.frame.meta.files)
if (files.length === 0) {
return undefined
@@ -568,7 +590,7 @@ function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefine
}
}
function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot {
function snapTask(p: ToolProps): ToolSnapshot {
const kind = Locale.titlecase(p.input.subagent_type || "general")
const desc = p.input.description
const title = text(p.frame.state.title)
@@ -582,7 +604,7 @@ function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot {
}
}
function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
function snapTodo(p: ToolProps): ToolSnapshot {
const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => {
const content = typeof item?.content === "string" ? item.content : ""
if (!content) {
@@ -604,7 +626,7 @@ function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
}
}
function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
function snapQuestion(p: ToolProps): ToolSnapshot {
const answers = list<unknown[]>(p.frame.meta.answers)
const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
const answer = list<string>(answers[i]).filter((entry) => typeof entry === "string")
@@ -621,7 +643,7 @@ function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
}
}
function scrollBashStart(p: ToolProps<typeof BashTool>): string {
function scrollBashStart(p: ToolProps): string {
const cmd = p.input.command ?? ""
const wd = p.input.workdir ?? ""
const formatted = wd && wd !== "." ? toolPath(wd) : ""
@@ -637,7 +659,7 @@ function scrollBashStart(p: ToolProps<typeof BashTool>): string {
return `# Running in ${dir}\n$ ${cmd}`
}
function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
function scrollBashProgress(p: ToolProps): string {
const out = stripAnsi(p.frame.raw)
const cmd = (p.input.command ?? "").trim()
const fmt = (text: string) => {
@@ -670,7 +692,11 @@ function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
return fmt(out)
}
function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
function scrollBashFinal(p: ToolProps): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
const code = p.metadata.exit ?? num(p.frame.meta.exitCode) ?? num(p.frame.meta.exit_code)
const time = span(p.frame.state)
if (code === undefined) {
@@ -684,22 +710,22 @@ function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
return `bash completed (exit ${code})${time ? ` · ${time}` : ""}`
}
function scrollReadStart(p: ToolProps<typeof ReadTool>): string {
function scrollReadStart(p: ToolProps): string {
const file = toolPath(p.input.filePath)
const extra = info(p.frame.input, ["filePath"])
const tail = extra ? ` ${extra}` : ""
return `→ Read ${file}${tail}`.trim()
}
function scrollWriteStart(_: ToolProps<typeof WriteTool>): string {
function scrollWriteStart(_: ToolProps): string {
return ""
}
function scrollEditStart(_: ToolProps<typeof EditTool>): string {
function scrollEditStart(_: ToolProps): string {
return ""
}
function scrollPatchStart(_: ToolProps<typeof ApplyPatchTool>): string {
function scrollPatchStart(_: ToolProps): string {
return ""
}
@@ -723,7 +749,7 @@ function patchLine(file: PatchFile): string {
return `~ Patched ${rel || toolPath(from)}`
}
function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
function scrollPatchFinal(p: ToolProps): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
@@ -752,7 +778,7 @@ function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
return patchLine(files[0]!)
}
function scrollTaskStart(_: ToolProps<typeof TaskTool>): string {
function scrollTaskStart(_: ToolProps): string {
return ""
}
@@ -774,7 +800,7 @@ function taskResult(output: string): string | undefined {
return next || undefined
}
function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
function scrollTaskFinal(p: ToolProps): string {
if (p.frame.status === "error") {
return fail(p.frame)
}
@@ -788,11 +814,11 @@ function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
return `# ${kind} Task\n${row}`
}
function scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string {
function scrollTodoStart(_: ToolProps): string {
return ""
}
function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
function scrollTodoFinal(p: ToolProps): string {
const items = list<{ status?: string }>(p.input.todos)
const time = span(p.frame.state)
if (items.length === 0) {
@@ -824,11 +850,11 @@ function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
return tail.join(" · ")
}
function scrollQuestionStart(_: ToolProps<typeof QuestionTool>): string {
function scrollQuestionStart(_: ToolProps): string {
return ""
}
function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
function scrollQuestionFinal(p: ToolProps): string {
const q = p.input.questions ?? []
const a = p.metadata.answers ?? []
const time = span(p.frame.state)
@@ -855,15 +881,15 @@ function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
return rows.join("\n")
}
function scrollLspStart(p: ToolProps<typeof LspTool>): string {
function scrollLspStart(p: ToolProps): string {
return `${lspTitle(p.input)}`
}
function scrollSkillStart(p: ToolProps<typeof SkillTool>): string {
function scrollSkillStart(p: ToolProps): string {
return `→ Skill "${p.input.name ?? ""}"`
}
function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
function scrollGlobStart(p: ToolProps): string {
const pattern = p.input.pattern ?? ""
const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob"
const dir = p.input.path ?? ""
@@ -874,11 +900,11 @@ function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
return `${head} in ${toolPath(dir)}`
}
function scrollGlobFinal(p: ToolProps<typeof GlobTool>): string {
function scrollGlobFinal(p: ToolProps): string {
return toolError(p.frame) || fail(p.frame)
}
function scrollGrepStart(p: ToolProps<typeof GrepTool>): string {
function scrollGrepStart(p: ToolProps): string {
const pattern = p.input.pattern ?? ""
const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep"
const dir = p.input.path ?? ""
@@ -898,7 +924,7 @@ function scrollListStart(p: ToolProps): string {
return `→ List ${toolPath(dir)}`
}
function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
function scrollWebfetchStart(p: ToolProps): string {
const url = p.input.url ?? ""
if (!url) {
return "% WebFetch"
@@ -907,7 +933,7 @@ function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
return `% WebFetch ${url}`
}
function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
function scrollWebSearchStart(p: ToolProps): string {
const title = webSearchProviderLabel(p.metadata.provider)
const query = p.input.query ?? ""
if (!query) {
@@ -917,7 +943,7 @@ function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
return `${title} "${query}"`
}
function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
function permEdit(p: ToolPermissionProps): ToolPermissionInfo {
const input = p.input as { filePath?: string; filepath?: string; diff?: string }
const file = input.filePath || input.filepath || p.patterns[0] || ""
return {
@@ -929,7 +955,7 @@ function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
}
}
function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
function permRead(p: ToolPermissionProps): ToolPermissionInfo {
const file = p.input.filePath || p.patterns[0] || ""
return {
icon: "→",
@@ -938,7 +964,7 @@ function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
}
}
function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
function permGlob(p: ToolPermissionProps): ToolPermissionInfo {
const pattern = p.input.pattern || p.patterns[0] || ""
return {
icon: "✱",
@@ -947,7 +973,7 @@ function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
}
}
function permGrep(p: ToolPermissionProps<typeof GrepTool>): ToolPermissionInfo {
function permGrep(p: ToolPermissionProps): ToolPermissionInfo {
const pattern = p.input.pattern || p.patterns[0] || ""
return {
icon: "✱",
@@ -965,7 +991,7 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
}
}
function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
function permBash(p: ToolPermissionProps): ToolPermissionInfo {
const cmd = p.input.command || ""
return {
icon: "#",
@@ -974,7 +1000,7 @@ function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
}
}
function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
function permTask(p: ToolPermissionProps): ToolPermissionInfo {
const type = p.input.subagent_type || "general"
const desc = p.input.description
return {
@@ -984,7 +1010,7 @@ function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
}
}
function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissionInfo {
function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo {
const url = p.input.url || ""
return {
icon: "%",
@@ -993,7 +1019,7 @@ function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissi
}
}
function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermissionInfo {
function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo {
const query = p.input.query || ""
const title = webSearchProviderLabel(p.metadata.provider)
return {
@@ -1003,7 +1029,7 @@ function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermis
}
}
function permLsp(p: ToolPermissionProps<typeof LspTool>): ToolPermissionInfo {
function permLsp(p: ToolPermissionProps): ToolPermissionInfo {
const file = p.input.filePath || ""
const line = typeof p.input.line === "number" ? p.input.line : undefined
const char = typeof p.input.character === "number" ? p.input.character : undefined
@@ -1269,7 +1295,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
}
}
function runBash(p: ToolProps<typeof BashTool>): ToolInline {
function runBash(p: ToolProps): ToolInline {
return {
icon: "$",
title: p.input.command || "",
@@ -1427,6 +1453,11 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody |
return textBody(shellOutput(commit.shell.command, raw) ?? "")
}
if (commit.toolState === "error") {
const ctx = toolFrame(commit, raw)
return textBody(toolScroll("final", ctx))
}
return undefined
}
@@ -11,7 +11,8 @@
// → stream.ts bridges to footer API
// → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import type { TuiConfig } from "@opencode-ai/tui/config"
export type RunFilePart = {
@@ -21,10 +22,17 @@ export type RunFilePart = {
mime: string
}
type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
type PromptModel = { providerID: string; modelID: string }
export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
export type RunPromptPart =
| {
type: "file"
url: string
filename?: string
mime?: string
source?: FilePart["source"]
}
| { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
export type RunCommand = {
name: string
@@ -93,6 +101,8 @@ export type RunPrompt = {
command?: {
name: string
arguments: string
// Catalog source of the matched slash entry ("skill" routes to session.skill).
source?: string
}
}
@@ -109,12 +119,10 @@ export type RunAgent = {
hidden: boolean
}
export type RunReference = NonNullable<
Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["data"]
>["data"][number]
export type RunReference = ReferenceListOutput["data"][number]
export type RunInput = {
sdk: OpencodeClient
sdk: OpenCodeClient
directory: string
sessionID: string
sessionTitle?: string
@@ -280,6 +288,10 @@ export type FooterOutput = {
// transport both emit these to update footer state without reaching into
// internal signals directly.
export type FooterEvent =
| {
type: "history"
history: RunPrompt[]
}
| {
type: "catalog"
agents: RunAgent[]
@@ -310,6 +322,7 @@ export type FooterEvent =
| {
type: "model"
model: string
selection: NonNullable<RunInput["model"]>
}
| {
type: "turn.send"
@@ -339,11 +352,14 @@ export type FooterEvent =
state: FooterSubagentState
}
export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
export type PermissionReply = Omit<Parameters<OpenCodeClient["permission"]["reply"]>[0], "sessionID">
export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
export type QuestionReply = {
requestID: string
answers: string[][]
}
export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
export type QuestionReject = Omit<Parameters<OpenCodeClient["question"]["reject"]>[0], "sessionID">
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
+27
View File
@@ -0,0 +1,27 @@
import { EOL } from "node:os"
export const Style = {
TEXT_DIM: "\x1b[90m",
TEXT_NORMAL: "\x1b[0m",
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
}
export function println(...message: string[]) {
process.stderr.write(message.join(" ") + EOL)
}
let blank = false
export function empty() {
if (blank) return
println(Style.TEXT_NORMAL)
blank = true
}
export function error(message: string) {
if (message.startsWith("Error: ")) message = message.slice("Error: ".length)
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
export * as UI from "./ui"
@@ -12,9 +12,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { makeRuntime } from "@/effect/run-service"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Global } from "@opencode-ai/core/global"
import { isRecord } from "@/util/record"
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
import type { RunInput, RunProvider } from "./types"
@@ -32,6 +31,10 @@ type VariantRuntime = {
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
function modelKey(provider: string, model: string): string {
+134
View File
@@ -0,0 +1,134 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AppProcess } from "@opencode-ai/core/process"
import { Flock } from "@opencode-ai/core/util/flock"
import { start } from "@opencode-ai/server/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { Updater } from "./services/updater"
export type Mode = "default" | "service" | "stdio"
export type Options = {
readonly mode: Mode
readonly hostname?: string
readonly port?: number
}
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(NodeServices.layer),
),
)
const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
if (options.mode === "service") {
const service = yield* ServiceConfig.options()
yield* Flock.effect(path.basename(service.file, ".json") + "-process", {
dir: path.dirname(service.file),
staleMs: 3_000,
timeoutMs: 15_000,
})
}
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
if (options.mode === "stdio") {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const password =
options.mode === "service"
? yield* ServiceConfig.password()
: environmentPassword
? Redacted.value(environmentPassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* start({
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
port: Option.fromNullishOr(options.port ?? config.port),
password,
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
if (options.mode === "service") yield* register(address, password)
const url = HttpServer.formatAddress(address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "stdio" ? waitForStdinClose() : Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
})
// The latest atomic registration wins. A displaced process notices the new id,
// exits, and cannot remove its successor's registration from its finalizer.
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) {
const fs = yield* FileSystem.FileSystem
const options = yield* ServiceConfig.options()
const id = randomUUID()
const temp = options.file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
const encoded = yield* encodeInfo({
id,
version: InstallationVersion,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
})
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
yield* fs.rename(temp, options.file)
const currentID = fs.readFileString(options.file).pipe(
Effect.flatMap(decodeInfo),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
yield* currentID.pipe(
Effect.flatMap((current) =>
current === id
? Effect.void
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
),
Effect.repeat(Schedule.spaced("10 seconds")),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() =>
currentID.pipe(
Effect.flatMap((current) => (current === id ? fs.remove(options.file) : Effect.void)),
Effect.ignore,
),
)
})
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
process.stdin.once("end", close)
process.stdin.once("close", close)
process.stdin.resume()
if (process.stdin.readableEnded || process.stdin.destroyed) close()
return Effect.sync(() => {
process.stdin.off("end", close)
process.stdin.off("close", close)
process.stdin.pause()
})
})
}
+16 -6
View File
@@ -9,11 +9,17 @@ import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
function command(password: string) {
type Options = {
readonly command?: ReadonlyArray<string>
}
function command(password: string, options: Options) {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"]
if (!executable) throw new Error("Failed to resolve standalone server command")
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
cwd: process.cwd(),
// Explicit entry wins over anything inherited, so a user-exported
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
@@ -28,17 +34,21 @@ function command(password: string) {
})
}
export const transport = Effect.fn("cli.standalone.transport")(
function* () {
const makeTransport = Effect.fn("cli.standalone.transport")(
function* (options: Options) {
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password))
const proc = yield* spawner.spawn(command(password, options))
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid }
},
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
)
export function transport(options: Options = {}) {
return makeTransport(options)
}
export * as Standalone from "./standalone"
@@ -8,7 +8,8 @@ await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* Standalone.transport()
console.log(`${transport.pid} ${transport.url}`)
const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers }))
console.log(`${transport.pid} ${transport.url} ${response.status}`)
return yield* Effect.never
}),
),
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, test } from "bun:test"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "node:path"
import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini"
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
describe("mini command", () => {
test("uses piped stdin as the initial prompt", () => {
expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
})
test("keeps run as mini's non-interactive input mode", () => {
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
})
test("applies a variant to a resumed session's model", () => {
expect(
pickRunModel(
undefined,
"high",
{ providerID: "session-provider", modelID: "session-model" },
{ providerID: "default-provider", modelID: "default-model" },
),
).toEqual({ providerID: "session-provider", modelID: "session-model" })
})
test("is registered in the preview CLI", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("mini Start the minimal interactive interface")
expect(result.stdout).toContain("run Run OpenCode with a message")
})
test("exposes run without legacy attach or command modes", async () => {
const result = await cli(["run", "--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string")
expect(result.stdout).not.toContain("--attach")
expect(result.stdout).not.toContain("--command")
})
test("keeps option-like prompt text after the argument separator", async () => {
const result = await cli(["run", "--server", "http://127.0.0.1:1", "--", "--foo"])
expect(result.exitCode).toBe(1)
expect(result.stderr).not.toContain("You must provide a message")
})
test("preserves a run failure exit code", async () => {
let modelRequests = 0
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health")
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
if (url.pathname === "/api/model") {
modelRequests++
return Response.json({
location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
})
}
return new Response(undefined, { status: 404 })
},
})
try {
const result = await cli([
"run",
"--server",
server.url.toString(),
"--dir",
process.cwd(),
"--model",
"definitely/missing",
"hi",
])
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("Model unavailable: definitely/missing")
} finally {
server.stop(true)
}
})
test("reports pre-admission errors as JSON", async () => {
const server = Bun.serve({
port: 0,
fetch() {
return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
},
})
try {
const result = await cli(["run", "--format", "json", "--server", server.url.toString(), "hi"])
expect(result.exitCode).toBe(1)
expect(JSON.parse(result.stdout)).toMatchObject({
type: "error",
sessionID: "",
error: { type: "unknown", message: expect.stringContaining("requires") },
})
} finally {
server.stop(true)
}
})
test("uses the shared V2 server option instead of an attach command", async () => {
const result = await cli(["mini", "--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string")
expect(result.stdout).not.toContain("SUBCOMMANDS")
})
test("routes local and explicit-server invocations into mini", async () => {
for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) {
const result = await cli(args)
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("opencode mini requires a TTY stdout")
}
})
})
+3 -2
View File
@@ -4,18 +4,19 @@ import path from "node:path"
test("standalone server exits when its owner is killed", async () => {
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."),
env: process.env,
env: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
const [rawPID, url] = line?.split(" ") ?? []
const [rawPID, url, status] = line?.split(" ") ?? []
const pid = Number(rawPID)
try {
expect(pid).toBeGreaterThan(0)
expect(url).toStartWith("http://127.0.0.1:")
expect(status).toBe("200")
expect(running(pid)).toBe(true)
owner.kill("SIGKILL")
+17 -5
View File
@@ -1,16 +1,30 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/client",
"private": true,
"version": "1.17.13",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/client"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
"./promise": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts"
"./promise/api": "./src/promise/api.ts",
"./effect": "./src/effect/index.ts",
"./effect/api": "./src/effect/api.ts"
},
"scripts": {
"build": "bun run script/build-package.ts",
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
@@ -28,9 +42,7 @@
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
+2 -2
View File
@@ -32,8 +32,8 @@ await Effect.runPromise(
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
),
write(
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }),
fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)),
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
),
],
{ concurrency: 3, discard: true },
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+8
View File
@@ -0,0 +1,8 @@
import type { ModelApi, ProviderApi } from "./api/api.js"
export type * from "./api/api.js"
export interface CatalogApi<E = never> {
readonly provider: ProviderApi<E>
readonly model: ModelApi<E>
}
@@ -1,7 +1,7 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import type { Effect, Stream } from "effect"
import type { HttpApiClient } from "effect/unstable/httpapi"
import type { ClientApi } from "@opencode-ai/protocol/client"
import type { ClientApi } from "../../contract"
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never
@@ -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>
}
@@ -862,6 +866,13 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E>
}
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
export interface DebugApi<E = never> {
readonly location: DebugLocationOperation<E>
}
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly location: LocationApi<E>
@@ -888,4 +899,5 @@ export interface AppApi<E = never> {
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
}
+20 -10
View File
@@ -544,25 +544,29 @@ const Endpoint11_1 = (raw: RawClient["server.credential"]) => (input: Endpoint11
const adaptGroup11 = (raw: RawClient["server.credential"]) => ({ update: Endpoint11_0(raw), remove: Endpoint11_1(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.project"]["project.current"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.project"]) => (input?: Endpoint12_0Input) =>
const Endpoint12_0 = (raw: RawClient["server.project"]) => () =>
raw["project.list"]({}).pipe(Effect.mapError(mapClientError))
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.current"]>[0]
type Endpoint12_1Input = { readonly location?: Endpoint12_1Request["query"]["location"] }
const Endpoint12_1 = (raw: RawClient["server.project"]) => (input?: Endpoint12_1Input) =>
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint12_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
type Endpoint12_1Input = {
readonly projectID: Endpoint12_1Request["params"]["projectID"]
readonly location?: Endpoint12_1Request["query"]["location"]
type Endpoint12_2Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
type Endpoint12_2Input = {
readonly projectID: Endpoint12_2Request["params"]["projectID"]
readonly location?: Endpoint12_2Request["query"]["location"]
}
const Endpoint12_1 = (raw: RawClient["server.project"]) => (input: Endpoint12_1Input) =>
const Endpoint12_2 = (raw: RawClient["server.project"]) => (input: Endpoint12_2Input) =>
raw["project.directories"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.project"]) => ({
current: Endpoint12_0(raw),
directories: Endpoint12_1(raw),
list: Endpoint12_0(raw),
current: Endpoint12_1(raw),
directories: Endpoint12_2(raw),
})
type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
@@ -1043,6 +1047,11 @@ const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) })
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
location: adaptGroup1(raw["server.location"]),
@@ -1069,6 +1078,7 @@ const adaptClient = (raw: RawClient) => ({
reference: adaptGroup22(raw["server.reference"]),
projectCopy: adaptGroup23(raw["server.projectCopy"]),
vcs: adaptGroup24(raw["server.vcs"]),
debug: adaptGroup25(raw["server.debug"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
+17
View File
@@ -1,6 +1,22 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
import type { Effect } from "effect"
export * from "./generated/index"
export type {
AgentApi,
AppApi,
CatalogApi,
CommandApi,
EventApi,
IntegrationApi,
ModelApi,
PluginApi,
ProviderApi,
ReferenceApi,
SessionApi,
SkillApi,
} from "./api.js"
export { Service } from "./service.js"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
@@ -27,3 +43,4 @@ export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
+41 -12
View File
@@ -31,11 +31,14 @@ export type Options = {
// Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to start() is the caller's policy.
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
return (yield* discoverLocal(options))?.transport
})
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
const info = yield* read(options.file)
if (info === undefined) return undefined
if (options.version !== undefined && info.version !== options.version) return undefined
const found = yield* probe(info)
return found?.transport
return yield* probe(info, options.version)
})
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
@@ -48,18 +51,29 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
yield* Effect.try({
const child = yield* Effect.try({
try: () => {
spawn(command, args, { detached: true, stdio: "ignore" }).unref()
const child = spawn(command, args, { detached: true, stdio: "ignore" })
child.unref()
return child
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
return yield* discover(options).pipe(
return yield* discoverLocal(options).pipe(
Effect.flatMap((found) =>
found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
),
Effect.retry(poll),
Effect.tap((found) =>
found.info.pid === child.pid
? Effect.void
: Effect.sync(() => {
child.kill("SIGTERM")
}),
),
Effect.map((found) => found.transport),
Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
Effect.mapError(() => new Error("Failed to start server")),
)
})
@@ -90,6 +104,10 @@ export const Info = Schema.Struct({
export type Info = typeof Info.Type
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).
@@ -105,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
})
@@ -125,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.
+41
View File
@@ -0,0 +1,41 @@
import type {
AgentApi as EffectAgentApi,
CommandApi as EffectCommandApi,
EventApi as EffectEventApi,
IntegrationApi as EffectIntegrationApi,
ModelApi as EffectModelApi,
PluginApi as EffectPluginApi,
ProviderApi as EffectProviderApi,
ReferenceApi as EffectReferenceApi,
SessionApi as EffectSessionApi,
SkillApi as EffectSkillApi,
} from "../effect/api/api.js"
import type { Effect, Stream } from "effect"
type PromisifyOperation<Operation> = Operation extends (
...args: infer Args
) => Effect.Effect<infer Success, unknown, unknown>
? (...args: Args) => Promise<Success>
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
? (...args: Args) => AsyncIterable<Success>
: Operation
type PromisifyApi<Api> = {
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>
}
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
export interface CatalogApi {
readonly provider: ProviderApi
readonly model: ModelApi
}
@@ -89,6 +89,7 @@ import type {
CredentialUpdateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectDirectoriesInput,
@@ -173,6 +174,7 @@ import type {
VcsStatusOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -898,6 +900,11 @@ export function make(options: ClientOptions) {
),
},
project: {
list: (requestOptions?: RequestOptions) =>
request<ProjectListOutput>(
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>(
{
@@ -1448,6 +1455,19 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
debug: {
location: (requestOptions?: RequestOptions) =>
request<DebugLocationOutput>(
{
method: "GET",
path: `/api/debug/location`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
}
}
+229 -83
View File
@@ -157,7 +157,7 @@ export type ProjectCopyError = {
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
export type HealthGetOutput = { readonly healthy: true }
export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
export type LocationGetInput = {
readonly location?: {
@@ -928,6 +928,7 @@ export type SessionContextOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
@@ -976,9 +977,27 @@ export type SessionContextOutput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly callID: string
readonly command: string
readonly output: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
@@ -1109,7 +1128,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "agent.selected"
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly agent: string }
@@ -1118,7 +1137,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "model.selected"
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1143,7 +1162,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "renamed"
readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string }
@@ -1152,7 +1171,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "forked"
readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string }
@@ -1161,7 +1180,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.promoted"
readonly type: "session.prompt.promoted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string }
@@ -1170,7 +1189,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.admitted"
readonly type: "session.prompt.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1206,7 +1225,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "synthetic"
readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1220,7 +1239,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.activated"
readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly name: string; readonly text: string }
@@ -1229,25 +1248,59 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.started"
readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string }
readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.ended"
readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string }
readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
readonly output: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.started"
readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1262,7 +1315,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.ended"
readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1284,7 +1337,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.failed"
readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1297,7 +1350,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.started"
readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
@@ -1306,7 +1359,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.ended"
readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1320,7 +1373,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.started"
readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1334,7 +1387,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.ended"
readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1349,7 +1402,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.started"
readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1363,7 +1416,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.ended"
readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1377,7 +1430,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.called"
readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1396,7 +1449,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.progress"
readonly type: "session.tool.progress"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1414,7 +1467,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.success"
readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1438,7 +1491,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.failed"
readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1457,7 +1510,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "retried"
readonly type: "session.retried"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1477,7 +1530,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.started"
readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" }
@@ -1486,7 +1539,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.ended"
readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1500,7 +1553,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.staged"
readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1524,7 +1577,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.cleared"
readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
@@ -1533,7 +1586,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.committed"
readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string }
@@ -1569,6 +1622,7 @@ export type SessionMessageOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
@@ -1617,9 +1671,27 @@ export type SessionMessageOutput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly callID: string
readonly command: string
readonly output: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
@@ -1750,6 +1822,7 @@ export type MessageListOutput = {
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
| {
readonly id: string
@@ -1798,9 +1871,27 @@ export type MessageListOutput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly callID: string
readonly command: string
readonly output: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
@@ -2339,7 +2430,7 @@ export type ServerMcpListOutput = {
readonly name: string
readonly status:
| { readonly status: "connected" }
| { readonly status: "disconnected" }
| { readonly status: "pending" }
| { readonly status: "disabled" }
| { readonly status: "failed"; readonly error: string }
| { readonly status: "needs_auth" }
@@ -2367,6 +2458,17 @@ export type CredentialRemoveInput = {
export type CredentialRemoveOutput = void
export type ProjectListOutput = ReadonlyArray<{
readonly id: string
readonly worktree: string
readonly vcs?: string
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
@@ -4309,7 +4411,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "agent.selected"
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly agent: string }
@@ -4318,7 +4420,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "model.selected"
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4343,7 +4445,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "renamed"
readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string }
@@ -4352,7 +4454,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "forked"
readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string }
@@ -4361,7 +4463,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.promoted"
readonly type: "session.prompt.promoted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string }
@@ -4370,7 +4472,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.admitted"
readonly type: "session.prompt.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4397,7 +4499,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "execution.settled"
readonly type: "session.execution.settled"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
@@ -4418,7 +4520,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "synthetic"
readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4432,7 +4534,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.activated"
readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly name: string; readonly text: string }
@@ -4441,25 +4543,59 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.started"
readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string }
readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.ended"
readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string }
readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
readonly output: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.started"
readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4474,7 +4610,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.ended"
readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4496,7 +4632,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.failed"
readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4509,7 +4645,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.started"
readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
@@ -4518,7 +4654,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.delta"
readonly type: "session.text.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
@@ -4531,7 +4667,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.ended"
readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4545,7 +4681,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.started"
readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4559,7 +4695,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.delta"
readonly type: "session.reasoning.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
@@ -4572,7 +4708,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.ended"
readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4587,7 +4723,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.started"
readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4601,7 +4737,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.delta"
readonly type: "session.tool.input.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
@@ -4614,7 +4750,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.ended"
readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4628,7 +4764,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.called"
readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4647,7 +4783,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.progress"
readonly type: "session.tool.progress"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4665,7 +4801,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.success"
readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4689,7 +4825,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.failed"
readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4708,7 +4844,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "retried"
readonly type: "session.retried"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4728,7 +4864,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.started"
readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" }
@@ -4737,7 +4873,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.delta"
readonly type: "session.compaction.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly text: string }
}
@@ -4745,7 +4881,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.ended"
readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4759,7 +4895,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.staged"
readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4783,7 +4919,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.cleared"
readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
@@ -4792,7 +4928,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.committed"
readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string }
@@ -4801,9 +4937,9 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.edited"
readonly type: "filesystem.changed"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string }
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
}
| {
readonly id: string
@@ -4849,6 +4985,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
@@ -4869,7 +5013,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.updated"
readonly type: "config.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
@@ -4877,9 +5021,9 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.watcher.updated"
readonly type: "skill.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
readonly data: {}
}
| {
readonly id: string
@@ -5878,3 +6022,5 @@ export type VcsDiffOutput = {
readonly status?: "added" | "deleted" | "modified"
}>
}
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>
+13
View File
@@ -1,3 +1,16 @@
export * from "./generated/index"
export type {
AgentApi,
CatalogApi,
CommandApi,
EventApi,
IntegrationApi,
ModelApi,
PluginApi,
ProviderApi,
ReferenceApi,
SessionApi,
SkillApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
+10
View File
@@ -0,0 +1,10 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
declare const effectClient: EffectClient
const effectApi: EffectApi<unknown> = effectClient
void effectApi
+5 -5
View File
@@ -45,9 +45,9 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
return yield* client.event.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "model.selected"])
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
const durable = events[1]
if (durable?.type !== "model.selected") throw new Error("Expected model event")
if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
@@ -159,8 +159,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["model.selected", "log.synced"])
expect(logged[0]?.type === "model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
1_717_171_717_000,
)
expect(logged.at(-1)).toEqual(synced)
@@ -228,7 +228,7 @@ const modelSwitchedMessage = {
const modelSwitchedEvent = {
id: "evt_model",
created: 1_717_171_717_000,
type: "model.selected",
type: "session.model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: {
sessionID: "ses_test",
+4 -2
View File
@@ -30,7 +30,9 @@ test("exposes every standard HTTP API group", () => {
"reference",
"projectCopy",
"vcs",
"debug",
])
expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual([
"list",
@@ -160,7 +162,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
for await (const event of client.event.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "model.selected" && events[1].created).toBe(1_717_171_717_000)
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
@@ -329,7 +331,7 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
const modelSwitchedEvent = {
id: "evt_model",
created: 1_717_171_717_000,
type: "model.selected",
type: "session.model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: {
sessionID: "ses_test",
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
+2
View File
@@ -3,6 +3,8 @@
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": false,
"allowJs": false,
"noUncheckedIndexedAccess": false
},
"include": ["src"]
+8
View File
@@ -5,6 +5,14 @@
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
## OpenAPI
- Generate an operation only when its transport semantics are supported; otherwise return a precise `skipped` reason.
- Never guess parameter serialization or malformed security semantics. Unsupported serialization is skipped and malformed security fails closed.
- Render unresolved schema constructs as `unknown`, never as invented TypeScript names.
- Keep network reads bounded and map expected encoding, transport, and decoding failures to model-safe `ToolError` values.
- Test supported behavior directly; do not reproduce adapter algorithms in tests.
## Future Design Notes
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
+42 -19
View File
@@ -4,7 +4,7 @@ Effect-native confined code execution over explicit, schema-described tools.
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
The package is currently private to this workspace. Its API is designed around three uses:
The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
```ts
// One execution
@@ -13,9 +13,6 @@ yield * CodeMode.execute({ tools, code })
// A reusable runtime
const runtime = CodeMode.make({ tools, limits })
yield * runtime.execute(code)
// One agent-facing code tool
const codeTool = runtime.agentTool()
```
## Install
@@ -63,7 +60,7 @@ const result =
`)
```
`result` is always an `ExecuteResult`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well.
@@ -86,6 +83,8 @@ const tool = Tool.make({
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
### `CodeMode.execute`
Use `CodeMode.execute` for a single execution:
@@ -116,31 +115,32 @@ const runtime = CodeMode.make({
runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // ExecuteResult
runtime.agentTool() // { name, description, input, output, execute }
runtime.execute(source) // CodeMode.Result
```
`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
### Results
```ts
type ExecuteResult = ExecuteSuccess | ExecuteFailure
type Result = Success | Failure
interface ExecuteSuccess {
interface Success {
readonly ok: true
readonly value: Schema.Json
readonly value: CodeMode.DataValue
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
interface ExecuteFailure {
interface Failure {
readonly ok: false
readonly error: Diagnostic
readonly error: CodeMode.Diagnostic
readonly logs?: ReadonlyArray<string>
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
}
```
@@ -152,6 +152,31 @@ interface ExecuteFailure {
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
### OpenAPI tools
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
```ts
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const api = OpenAPI.fromSpec({
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
auth: {
resolve: ({ name, scopes, operation }) =>
name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
},
})
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
```
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
## Discovery
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
@@ -279,7 +304,7 @@ import { toolError } from "@opencode-ai/codemode"
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
```
Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary.
Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
## Authority Boundary
@@ -308,12 +333,10 @@ A program cannot gain authority through prose or generated code. It can only exe
The public contract is guided by these equivalences:
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
- `CodeMode.make(options).agentTool().execute({ code })` is equivalent to `CodeMode.make(options).execute(code)`.
- `CodeMode.make(options).agentTool().description` equals `CodeMode.make(options).instructions()`.
- A tool implementation is not invoked unless its input has decoded successfully.
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
- Host interruption remains interruption rather than an `ExecuteFailure`.
- Host interruption remains interruption rather than a `CodeMode.Failure`.
## Non-Goals
+24 -17
View File
@@ -220,9 +220,9 @@ wave; both packages typecheck clean.
(render-only - no validation, values pass through; rendering handles `$defs`/`definitions`
- `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
`tool-schema.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
anymore). Types `Tool.JsonSchema`/`Tool.SchemaType` exported from the index. Note: an empty
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
cosmetic, fixed in Wave 4.
- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output`
@@ -237,7 +237,7 @@ wave; both packages typecheck clean.
so failures are typed and observable). `message` is the model-safe failure message
(`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls
fire no end event (timeout kills the whole execution anyway).
- **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
- **Limits collapse**: public `CodeMode.ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5
later deleted that type and the internal limit system entirely.
@@ -246,7 +246,7 @@ maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other kn
serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte
output limit; return a smaller value]`; logs keep leading lines within the remaining budget
- `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to
`ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox
`CodeMode.Result`). UTF-8-safe truncation (no split code points). (The in-sandbox
`maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 -
truncation is now the only result-size mechanism.)
- **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a
@@ -313,7 +313,7 @@ real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still
packages typecheck clean.
- **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing
inline/search modes are gone - `DiscoveryMode` deleted, `DiscoveryOptions` is just
inline/search modes are gone - `DiscoveryMode` deleted, `CodeMode.DiscoveryOptions` is just
`{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to
`maxInlineCatalogTokens`, default 4,000 estimated tokens - see Post-wave fixes). Port of
the old opencode
@@ -340,7 +340,7 @@ packages typecheck clean.
read-the-description-before-calling guidance. (The flat prose layout this wave produced
was later replaced wholesale by the markdown-section restructure - see Post-wave fixes -
which also deleted this wave's worked example.)
- **Cosmetic renderer fixes** (`renderSchema` in `tool.ts`): an object schema with no
- **Cosmetic renderer fixes** (`renderSchema` in `tool-schema.ts`): an object schema with no
properties renders `{}` (was `{ }`), and the empty `Schema.Struct({})` emission
(`anyOf: [{ type: "object" }, { type: "array" }]`, no properties/items) collapses to `{}`
(was `{ } | Array<unknown>`).
@@ -469,7 +469,7 @@ adapter needed **no changes**.
`rankTools` algorithm in `packages/opencode/src/session/code-mode.ts` at git HEAD),
replacing the word-set ranker in `tool-runtime.ts`. Searchable text per tool = path +
description + input-schema property names + their `description` strings - extracted by
the new `inputProperties` helper in `tool.ts` (Effect Schemas via
the new `inputProperties` helper in `tool-schema.ts` (Effect Schemas via
`Schema.toJsonSchemaDocument`, the same emission signature rendering uses; JSON Schemas
read `properties` directly, resolving a trivial top-level `$ref`; try/catch falls back to
path + description). Queries tokenize on camelCase boundaries + non-alphanumeric
@@ -542,7 +542,7 @@ budget; namespaces must always be present):
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so
the package stays dependency-free; keep in sync if the core heuristic changes.
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
- `CodeMode.DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first
- stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
@@ -558,7 +558,7 @@ budget; namespaces must always be present):
**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as
configurable knobs; the internal limit system dies):
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
- `CodeMode.ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
@@ -575,7 +575,7 @@ configurable knobs; the internal limit system dies):
`maxCollectionLength` (every array-length/object-field-count check - this knob was
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
`ExecuteResultSchema` (fine - the package is unreleased).
`CodeMode.Result` (fine - the package is unreleased).
- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork
semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept
only because it produces a clearer error than a native stack-overflow RangeError; still
@@ -602,7 +602,7 @@ configurable knobs; the internal limit system dies):
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit
test - superseded by the package timeout regression test); rewrote the helpers that used
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
`InternalExecutionLimits` as a convenience to plain `CodeMode.ExecutionLimits`
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
suites: 34 + 16.
@@ -633,7 +633,7 @@ Semantics: each described input/output field carries its schema `description` as
express surface as JSDoc tags - `@deprecated`, `@default <json>` (unserializable defaults
skipped), `@format`, `@minItems`/`@maxItems`; `*/` inside text is neutralized to `* /`;
multiline descriptions become `*`-prefixed blocks with blank edges trimmed; undescribed,
untagged fields get no comment. Implementation: `renderSchema` in `tool.ts` grew a
untagged fields get no comment. Implementation: `renderSchema` in `tool-schema.ts` grew a
`RenderContext` (`{ definitions, pretty }`), a `MAX_RENDER_DEPTH = 8` recursion ceiling plus
a `$ref` `seen` guard (the renderer previously had neither - a cyclic `$defs` would have
looped; it now degrades to the ref name/`unknown`), and try/catch totality on the public
@@ -849,7 +849,7 @@ section 4 outer-truncation item the OPPOSITE way from "kill the outer one"):
that relied on the old default now asserts the oversized result reaches the shared
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
**Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default
**Docs polish** (post-API-review): stale `CodeMode.DiscoveryOptions` JSDoc fixed (claimed default
4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality)
and the README's incorrect "`effect` as a peer dependency" line corrected (`effect` is a
regular dependency; hosts depend on it themselves because the API surface is Effect-typed).
@@ -949,16 +949,16 @@ child calls" gap):
**Signature rendering + compound-assignment parity fixes** (externally reported, both
verified real with failing tests before fixing):
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema`
- **Non-identifier property names in rendered signatures** (`src/tool-schema.ts`): `renderSchema`
emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123`
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the
single `field` closure both the compact and pretty renderings share. The
`identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s
`identifierSegment` regex now lives in `tool-schema.ts` (internal) and `tool-runtime.ts`'s
bracket-notation `toolExpression` imports it: one source of truth for "is this a bare
identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
- **Numeric schema unions keep their real alternatives** (`src/tool-schema.ts`): the old
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
etc.). The collapse is now restricted to Effect's number-schema artifact
@@ -1132,6 +1132,12 @@ Post-MVP (logged, not blocking an experimental flag):
- [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`,
`session/system.ts:110-126`) still inject prose referencing server-native tool
names that are no longer directly callable under code mode.
- [ ] Tool-tree path segments named `__proto__`, `constructor`, or `prototype` are included
in discovery but rejected by `ToolRuntime` resolution even when supplied as safe own
properties on null-prototype host records. Hosts should preserve registered names rather
than invent incompatible aliases. CodeMode should own a consistent policy: safely admit
these names as own tool-tree members, reject them before catalog generation with a clear
diagnostic, or define one canonical escaping contract.
### Backlog / loose ends (non-blocking, any order)
@@ -1203,7 +1209,8 @@ Post-MVP (logged, not blocking an experimental flag):
the workspace is the implementation source of truth for v4 behavior questions.
- File map (this package): `src/codemode.ts` - types/limits/parser/Interpreter/execute/make;
`src/tool-runtime.ts` - tool tree, `copyIn`/`copyOut`, search/discovery, invoke path;
`src/tool.ts` - `Tool.make` + JSON-Schema->TS rendering; `src/values.ts` - sandbox value
`src/tool.ts` - public `Tool` definitions; `src/tool-schema.ts` - schema rendering and decoding;
`src/values.ts` - sandbox value
types; `src/tool-error.ts` - `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`.
- OpenCode file map (integration points): `src/tool/code-mode.ts` (the adapter, now a
registry tool service - `CodeModeTool` + `catalogInstructions`; formerly
+54 -106
View File
@@ -19,8 +19,7 @@ import { ToolError } from "./tool-error.js"
import { isSandboxValue, SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
export { ToolError, toolError } from "./tool-error.js"
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
/** Resource budgets enforced independently during each CodeMode program execution. */
export type ExecutionLimits = {
@@ -74,50 +73,20 @@ export type ExecuteOptions<Tools extends Record<string, unknown> = {}> = {
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Tools>>
}
/** A normalized program diagnostic safe to return across an agent tool boundary. */
export type Diagnostic = {
readonly kind: DiagnosticKind
readonly message: string
readonly location?: { readonly line: number; readonly column: number }
readonly suggestions?: ReadonlyArray<string>
}
/** A JSON value that can cross the confined interpreter boundary. */
export type DataValue = Schema.Json
/** Successful execution after the result has crossed the plain-data boundary. */
export type ExecuteSuccess = {
readonly ok: true
readonly value: DataValue
readonly logs?: ReadonlyArray<string>
/** Present when the value or logs were truncated to fit `maxOutputBytes`. */
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
}
/** Failed execution with calls admitted before the diagnostic was produced. */
export type ExecuteFailure = {
readonly ok: false
readonly error: Diagnostic
readonly logs?: ReadonlyArray<string>
/** Present when the logs were truncated to fit `maxOutputBytes`. */
readonly truncated?: boolean
readonly toolCalls: ReadonlyArray<ToolCall>
}
/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */
export type ExecuteResult = ExecuteSuccess | ExecuteFailure
/** Reusable CodeMode configuration shared by `execute` and `agentTool`. */
export type CodeModeOptions<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
/** Progressive-disclosure configuration for the agent-facing tool catalog. */
readonly discovery?: DiscoveryOptions
}
/** Input schema for the single agent-facing tool produced by `runtime.agentTool()`. */
export const ExecuteInputSchema = Schema.Struct({ code: Schema.String })
/** Schema for a host tool input containing CodeMode source. */
export const Input = Schema.Struct({ code: Schema.String })
export type Input = typeof Input.Type
const DiagnosticKindSchema = Schema.Literals([
export const DiagnosticKind = Schema.Literals([
"ParseError",
"UnsupportedSyntax",
"UnknownTool",
@@ -129,49 +98,52 @@ const DiagnosticKindSchema = Schema.Literals([
"ToolFailure",
"ExecutionFailure",
])
/** Stable categories produced by program, schema, tool, and limit failures. */
export type DiagnosticKind = typeof DiagnosticKind.Type
/** Structured success or diagnostic result schema returned by CodeMode execution. */
export const ExecuteResultSchema = Schema.Union([
Schema.Struct({
ok: Schema.Literal(true),
value: Schema.Json,
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })),
}),
Schema.Struct({
ok: Schema.Literal(false),
error: Schema.Struct({
kind: DiagnosticKindSchema,
message: Schema.String,
location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })),
suggestions: Schema.optionalKey(Schema.Array(Schema.String)),
}),
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })),
}),
])
export const Diagnostic = Schema.Struct({
kind: DiagnosticKind,
message: Schema.String,
location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })),
suggestions: Schema.optionalKey(Schema.Array(Schema.String)),
})
/** A normalized program diagnostic safe to return across an agent tool boundary. */
export type Diagnostic = typeof Diagnostic.Type
/** Agent-facing projection of a configured CodeMode runtime. */
export type AgentToolDefinition<R = never> = {
readonly name: "code"
readonly description: string
readonly input: typeof ExecuteInputSchema
readonly output: typeof ExecuteResultSchema
readonly execute: (input: { readonly code: string }) => Effect.Effect<ExecuteResult, never, R>
}
const ToolCallSchema = Schema.Struct({ name: Schema.String })
export const Success = Schema.Struct({
ok: Schema.Literal(true),
value: Schema.Json,
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(ToolCallSchema),
})
/** Successful execution after the result has crossed the plain-data boundary. */
export type Success = typeof Success.Type
export const Failure = Schema.Struct({
ok: Schema.Literal(false),
error: Diagnostic,
logs: Schema.optionalKey(Schema.Array(Schema.String)),
truncated: Schema.optionalKey(Schema.Boolean),
toolCalls: Schema.Array(ToolCallSchema),
})
/** Failed execution with calls admitted before the diagnostic was produced. */
export type Failure = typeof Failure.Type
/** Schema for the structured success or diagnostic returned by CodeMode execution. */
export const Result = Schema.Union([Success, Failure])
/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */
export type Result = typeof Result.Type
/** Reusable confined runtime over one explicit tool tree. */
export type CodeModeRuntime<R = never> = {
export type Runtime<R = never> = {
/** Lists schema-described tool paths provided by the host. */
readonly catalog: () => ReadonlyArray<ToolDescription>
/** Builds model-facing syntax guidance and visible tool signatures. */
readonly instructions: () => string
/** Projects the configured runtime as one agent-facing `code` tool. */
readonly agentTool: () => AgentToolDefinition<R>
/** Executes a program using this runtime's configured host tools. */
readonly execute: (code: string) => Effect.Effect<ExecuteResult, never, R>
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
type SourcePosition = {
@@ -286,19 +258,6 @@ const errorBrandName = (value: unknown): string | undefined =>
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
: undefined
/** Stable categories produced by program, schema, tool, and limit failures. */
export type DiagnosticKind =
| "ParseError"
| "UnsupportedSyntax"
| "UnknownTool"
| "InvalidToolInput"
| "InvalidToolOutput"
| "InvalidDataValue"
| "ToolCallLimitExceeded"
| "TimeoutExceeded"
| "ToolFailure"
| "ExecutionFailure"
const arrayMethods = new Set([
"map",
"filter",
@@ -3954,7 +3913,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
options: ExecuteOptions<Tools>,
limits: ResolvedExecutionLimits,
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
): Effect.Effect<Result, never, Services<Tools>> => {
const hooks = {
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
@@ -3986,7 +3945,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
value: result,
...logged(),
toolCalls: tools.calls,
} satisfies ExecuteResult
} satisfies Result
}).pipe((program) => {
const timeoutMs = limits.timeoutMs
if (timeoutMs === undefined) return program
@@ -3999,7 +3958,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
...logged(),
toolCalls: tools.calls,
} satisfies ExecuteResult),
} satisfies Result),
}),
)
})
@@ -4013,7 +3972,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
error: normalizeError(Cause.squash(cause)),
...logged(),
toolCalls: tools.calls,
} satisfies ExecuteResult),
} satisfies Result),
),
Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
)
@@ -4037,7 +3996,7 @@ const utf8Truncate = (value: string, maxBytes: number): string => {
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
*/
const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResult => {
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
let truncated = false
let value: DataValue = null
@@ -4079,7 +4038,7 @@ const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResu
export const execute = <const Tools extends Record<string, unknown>>(
options: ExecuteOptions<Tools>,
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
): Effect.Effect<Result, never, Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
@@ -4088,18 +4047,17 @@ export const execute = <const Tools extends Record<string, unknown>>(
/**
* Creates an Effect-native runtime over explicit, schema-described tools.
*
* Use `execute` for host-driven execution or `agentTool` to expose one confined code tool to an
* agent framework. Tool requirements remain in the returned Effect environment.
* Use `execute` for host-driven execution. Tool requirements remain in the returned Effect environment.
*
* @example
* ```ts
* const runtime = CodeMode.make({ tools: { orders: { lookup } } })
* const code = runtime.agentTool()
* const result = runtime.execute("return await tools.orders.lookup({ id: 'order_42' })")
* ```
*/
export const make = <const Tools extends Record<string, unknown> = {}>(
options: CodeModeOptions<Tools> = {} as CodeModeOptions<Tools>,
): CodeModeRuntime<Services<Tools>> => {
options: Options<Tools> = {} as Options<Tools>,
): Runtime<Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
const limits = resolveExecutionLimits(options.limits)
@@ -4111,16 +4069,6 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
return {
catalog: () => catalog,
instructions: () => instructions,
agentTool: () => ({
name: "code",
description: instructions,
input: ExecuteInputSchema,
output: ExecuteResultSchema,
execute: ({ code }) => executeProgram(code),
}),
execute: executeProgram,
}
}
/** Constructors for one-shot and reusable CodeMode execution. */
export const CodeMode = { make, execute }
+4 -21
View File
@@ -1,21 +1,4 @@
export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js"
export { Tool } from "./tool.js"
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
export type {
AgentToolDefinition,
CodeModeOptions,
CodeModeRuntime,
DataValue,
Diagnostic,
DiagnosticKind,
DiscoveryOptions,
ExecuteFailure,
ExecuteOptions,
ExecuteResult,
ExecuteSuccess,
ExecutionLimits,
ToolCall,
ToolCallStarted,
ToolDescription,
} from "./codemode.js"
export * as CodeMode from "./codemode.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { ToolError, toolError } from "./tool-error.js"
+19
View File
@@ -0,0 +1,19 @@
# OpenAPI Follow-ups
The initial adapter intentionally skips operations it cannot execute correctly. Future work may add:
- Cookie parameters, authentication, and cookie-header merging.
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
- External references and complete nested `$defs` support.
- Relative or templated server URLs and server variables.
- Base URLs containing query strings or fragments.
- Runtime response-schema validation and full content negotiation.
- Binary response values and explicit byte-oriented return types.
- Request/response projection for `readOnly` and `writeOnly` properties.
- SSE, WebSocket, and other streaming transports.
- Recovery of responses rejected by a status-filtering `HttpClient`.
- Configurable request and response size limits.
- Adapter-enforced redirect policy independent of the supplied `HttpClient`.
- Strict UTF-8 and empty-body validation for JSON responses.
- Compile-time rejection of parameter schemas with nested values unsupported by their serialization style; runtime rejects them before auth resolution.
- Complete malformed-security-scheme validation and broader auth-combination coverage.
+130
View File
@@ -0,0 +1,130 @@
import { HttpClient } from "effect/unstable/http"
import { make, type Definition } from "../tool.js"
import { invoke } from "./runtime.js"
import {
componentDefinitions,
inputSchema,
isRecord,
methods,
nonEmptyString,
operationInput,
operationOutput,
operationPath,
operationSecurityRequirements,
securityRequirements,
securitySchemes,
specServerUrl,
validateBaseUrl,
} from "./spec.js"
import type { Operation, Options, Result, Skipped, Tools } from "./types.js"
export type {
AuthResolver,
Credential,
Document,
Operation,
Options,
Result,
SecurityScheme,
Skipped,
Tools,
} from "./types.js"
/**
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
* operation. Auth is resolved host-side via `auth.resolve` and never
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
* operations land in `skipped`.
*/
export const fromSpec = (options: Options): Result => {
const document = options.spec
const schemes = securitySchemes(document)
const defaultSecurity = securityRequirements(document.security)
const definitions = componentDefinitions(document)
const paths = isRecord(document.paths) ? document.paths : {}
const used = new Set<string>()
const namespaces = new Set<string>()
const skipped: Array<Skipped> = []
const tools = Object.create(null) as Tools
for (const [path, pathValue] of Object.entries(paths)) {
if (!isRecord(pathValue)) continue
for (const [method, operationValue] of Object.entries(pathValue)) {
if (!methods.has(method) || !isRecord(operationValue)) continue
const segments = operationPath(method, path, operationValue, used, namespaces)
const operation: Operation = {
operationId: nonEmptyString(operationValue.operationId),
method: method.toUpperCase(),
path,
summary: nonEmptyString(operationValue.summary),
description: nonEmptyString(operationValue.description),
}
const output = operationOutput(document, operationValue, definitions)
if (!output.ok) {
skipped.push({ method: operation.method, path, reason: output.reason })
continue
}
const resolvedBaseUrl = (() => {
if (options.baseUrl !== undefined) return validateBaseUrl(options.baseUrl)
if (operationValue.servers !== undefined) return specServerUrl(operationValue)
if (pathValue.servers !== undefined) return specServerUrl(pathValue)
return specServerUrl(document)
})()
if (!resolvedBaseUrl.ok) {
skipped.push({ method: operation.method, path, reason: resolvedBaseUrl.reason })
continue
}
const parsedInput = operationInput(document, pathValue, operationValue)
if (!parsedInput.ok) {
skipped.push({ method: operation.method, path, reason: parsedInput.reason })
continue
}
const input = parsedInput.value
const security = operationSecurityRequirements(operationValue.security, defaultSecurity, schemes)
if (!security.ok) {
skipped.push({ method: operation.method, path, reason: security.reason })
continue
}
const plan = {
operation,
url: `${resolvedBaseUrl.value.replace(/\/+$/, "")}${path}`,
fields: input.fields,
body: input.body,
security: security.value,
schemes,
auth: options.auth,
headers: options.headers ?? {},
}
used.add(segments.join("."))
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
setTool(
tools,
segments,
make({
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
input: inputSchema(input.fields, definitions),
output: output.value,
run: (input) => invoke(plan, input),
}),
)
}
}
return { tools, skipped }
}
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
const [head, ...rest] = path
if (head === undefined) return
if (rest.length === 0) {
tools[head] = definition
return
}
const child = tools[head]
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
tools[head] = Object.create(null) as Tools
}
setTool(tools[head] as Tools, rest, definition)
}
+324
View File
@@ -0,0 +1,324 @@
import { Effect, Option, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse, type HttpMethod } from "effect/unstable/http"
import { ToolError, toolError } from "../tool-error.js"
import { isRecord, own } from "./spec.js"
import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const maxErrorBodyChars = 1_024
const maxResponseBodyBytes = 50 * 1024 * 1024
export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
Effect.gen(function* () {
const value = isRecord(input) ? input : {}
let request = yield* buildRequest(plan, value)
const auth = yield* resolveAuth(plan)
for (const [name, item] of Object.entries(auth.query)) {
request = HttpClientRequest.setUrlParam(request, name, item)
}
request = HttpClientRequest.setHeaders(request, auth.headers)
const client = yield* HttpClient.HttpClient
const response = yield* client
.execute(request)
.pipe(
Effect.catch((cause) =>
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
),
)
const text = yield* readResponseBody(response, plan)
const mediaType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase()
const json = mediaType === "application/json" || mediaType?.endsWith("+json") === true
const decoded = text === "" ? Option.some(null) : json ? decodeJson(text) : Option.none()
const parsed = json ? Option.getOrElse(decoded, () => text) : text === "" ? null : text
if (response.status < 200 || response.status >= 300) {
const rendered = typeof parsed === "string" ? parsed : (JSON.stringify(parsed) ?? "")
const summary =
rendered === "" || rendered === "null"
? "no response body"
: rendered.length > maxErrorBodyChars
? `${rendered.slice(0, maxErrorBodyChars)}...`
: rendered
return yield* Effect.fail(
toolError(`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summary}`),
)
}
if (json && Option.isNone(decoded)) {
return yield* Effect.fail(
toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`),
)
}
return parsed
})
const buildRequest = (
plan: Plan,
input: Readonly<Record<string, unknown>>,
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
Effect.gen(function* () {
// Validate every model-controlled value before auth resolution, which may refresh tokens.
const url = buildUrl(plan, input)
if (url instanceof ToolError) return yield* Effect.fail(url)
const missing = plan.fields.find(
(field) => field.required && field.location !== "path" && own(input, field.inputName) === undefined,
)
if (missing !== undefined) {
const label = missing.location === "body" ? "body field" : `${missing.location} parameter`
return yield* Effect.fail(toolError(`Missing required ${label} '${missing.inputName}'.`))
}
let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
for (const field of plan.fields) {
if (field.location !== "query") continue
const item = own(input, field.inputName)
if (item === undefined) continue
const serialized = serializeQuery(request, field, item)
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
request = serialized
}
// Host headers first, then declared header parameters.
request = HttpClientRequest.setHeaders(request, plan.headers)
for (const field of plan.fields) {
if (field.location !== "header") continue
const item = own(input, field.inputName)
if (item === undefined) continue
const serialized = serializeSimple(field, item, String)
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
request = HttpClientRequest.setHeader(request, field.name, serialized)
}
const setBody = (value: unknown, mediaType: string) =>
HttpClientRequest.bodyJson(request, value).pipe(
Effect.map((next) => HttpClientRequest.setHeader(next, "content-type", mediaType)),
Effect.mapError((cause) =>
toolError(`Invalid JSON body for ${plan.operation.method} ${plan.operation.path}.`, cause),
),
)
if (plan.body?.mode === "value") {
const field = plan.fields.find((field) => field.location === "body")
const body = field === undefined ? undefined : own(input, field.inputName)
if (body !== undefined) request = yield* setBody(body, plan.body.mediaType)
}
if (plan.body?.mode === "object") {
const entries = plan.fields.flatMap((field) => {
if (field.location !== "body") return []
const item = own(input, field.inputName)
return item === undefined ? [] : [[field.name, item] as const]
})
if (plan.body.required || entries.length > 0) {
request = yield* setBody(Object.fromEntries(entries), plan.body.mediaType)
}
}
return request
})
const resolveAuth = (plan: Plan): Effect.Effect<AppliedAuth, unknown> =>
Effect.gen(function* () {
const none: AppliedAuth = { headers: {}, query: {} }
if (plan.security.length === 0) return none
const unavailable: Array<string> = []
alternatives: for (const requirement of plan.security) {
const names = Object.keys(requirement)
if (names.length === 0) return none
const credentials: Array<readonly [string, SecurityScheme, Credential]> = []
for (const name of names) {
const scheme = own(plan.schemes, name)
if (scheme === undefined || plan.auth === undefined) {
unavailable.push(name)
continue alternatives
}
const credential = yield* plan.auth.resolve({
name,
definition: scheme,
scopes: requirement[name] ?? [],
operation: plan.operation,
})
if (credential === undefined) {
unavailable.push(name)
continue alternatives
}
credentials.push([name, scheme, credential])
}
const applied = applyCredentials(credentials)
return applied instanceof ToolError ? yield* Effect.fail(applied) : applied
}
return yield* Effect.fail(
toolError(
`${plan.operation.method} ${plan.operation.path} requires authentication; no credential available for: ${[...new Set(unavailable)].join(", ")}.`,
),
)
})
const applyCredentials = (
credentials: ReadonlyArray<readonly [string, SecurityScheme, Credential]>,
): AppliedAuth | ToolError => {
const headers = new Map<string, string>()
const query = new Map<string, string>()
const add = (carrier: "header" | "query", name: string, value: string): ToolError | undefined => {
const target = carrier === "header" ? headers : query
if (target.has(name)) return toolError(`Authentication resolves multiple credentials for ${carrier} '${name}'.`)
target.set(name, value)
}
for (const [name, definition, credential] of credentials) {
if (credential.type === "bearer") {
const duplicate = add("header", "authorization", `Bearer ${credential.token}`)
if (duplicate !== undefined) return duplicate
continue
}
if (credential.type === "basic") {
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
const duplicate = add(
"header",
"authorization",
`Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}`,
)
if (duplicate !== undefined) return duplicate
continue
}
if (credential.type === "header") {
const duplicate = add("header", credential.name.toLowerCase(), credential.value)
if (duplicate !== undefined) return duplicate
continue
}
// apiKey: the carrier comes from the scheme declaration.
if (definition.type !== "apiKey") {
return toolError(
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
)
}
if (definition.in === "cookie") return toolError(`Cookie authentication '${name}' is not supported.`)
const parameter = definition.in === "header" ? definition.name.toLowerCase() : definition.name
const duplicate = add(definition.in, parameter, credential.value)
if (duplicate !== undefined) return duplicate
}
return { headers: Object.fromEntries(headers), query: Object.fromEntries(query) }
}
const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string | ToolError => {
let url = plan.url
for (const field of plan.fields) {
if (field.location !== "path") continue
const item = own(input, field.inputName)
if (item === undefined) {
return toolError(`Missing required path parameter '${field.inputName}'.`)
}
const fieldValue = serializeSimple(field, item, (value) =>
encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
),
)
if (fieldValue instanceof ToolError) return fieldValue
// '.'/'..' survive encoding and URL normalization collapses them, letting a
// model-supplied value retarget the request to a different endpoint.
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
return toolError(`Invalid path parameter '${field.inputName}'.`)
}
url = url.replaceAll(`{${field.name}}`, fieldValue)
}
const unresolved = url.match(/\{[^{}]+\}/)
if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`)
return url
}
const serializeSimple = (
field: Plan["fields"][number],
value: unknown,
encode: (value: string) => string,
): string | ToolError => {
const scalar = (item: unknown): string | ToolError =>
item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean"
? toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`)
: encode(String(item))
if (Array.isArray(value)) {
const items = value.map(scalar)
const invalid = items.find((item): item is ToolError => item instanceof ToolError)
return invalid ?? items.join(",")
}
if (!isRecord(value)) return scalar(value)
const entries = Object.entries(value).flatMap<string | ToolError>(([name, item]) => {
const rendered = scalar(item)
if (rendered instanceof ToolError) return [rendered]
return field.explode ? [`${encode(name)}=${rendered}`] : [encode(name), rendered]
})
const invalid = entries.find((item): item is ToolError => item instanceof ToolError)
return invalid ?? entries.join(",")
}
const serializeQuery = (
request: HttpClientRequest.HttpClientRequest,
field: Plan["fields"][number],
value: unknown,
): HttpClientRequest.HttpClientRequest | ToolError => {
if (field.style === "deepObject") {
if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`)
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
if (current instanceof ToolError) return current
if (item === undefined || (item !== null && typeof item === "object")) {
return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`)
}
return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item))
}, request)
}
if (Array.isArray(value)) {
const rendered = serializeSimple(field, value, String)
if (rendered instanceof ToolError) return rendered
if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered)
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
}
return value.reduce(
(current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)),
request,
)
}
if (isRecord(value) && field.explode) {
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
if (current instanceof ToolError) return current
if (item === undefined || (item !== null && typeof item === "object")) {
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
}
return HttpClientRequest.appendUrlParam(current, name, String(item))
}, request)
}
const rendered = serializeSimple(field, value, String)
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
}
const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect<string, ToolError> =>
Effect.gen(function* () {
const contentLength = response.headers["content-length"]
const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
}
let body = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, declaredSize ?? 64 * 1024))
let size = 0
yield* Stream.runForEach(response.stream, (chunk) => {
if (size + chunk.byteLength > maxResponseBodyBytes) {
return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
}
if (size + chunk.byteLength > body.byteLength) {
const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
body.copy(grown, 0, 0, size)
body = grown
}
body.set(chunk, size)
size += chunk.byteLength
return Effect.void
}).pipe(
Effect.catch((cause) => {
if (cause instanceof ToolError) return Effect.fail(cause)
if (cause.reason._tag === "EmptyBodyError") return Effect.void
return Effect.fail(
toolError(`${plan.operation.method} ${plan.operation.path} failed while reading the response body.`, cause),
)
}),
)
return new TextDecoder().decode(body.subarray(0, size))
})
+507
View File
@@ -0,0 +1,507 @@
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
import type { JsonSchema } from "../tool.js"
import { isBlockedMember } from "../tool-runtime.js"
import type {
Body,
Document,
InputField,
OperationInput,
Parsed,
SecurityRequirement,
SecurityScheme,
} from "./types.js"
export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
const parameterLocations = ["path", "query", "header"] as const
const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value) ? value : [])
export const nonEmptyString = (value: unknown): string | undefined =>
typeof value === "string" && value !== "" ? value : undefined
// Guards record lookups keyed by spec- or model-controlled names against
// prototype-inherited values (e.g. a parameter named `toString`).
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
Object.hasOwn(record, key) ? record[key] : undefined
export const resolve = (document: Document, value: unknown): unknown => {
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
if (!isRecord(current)) return current
const ref = nonEmptyString(current.$ref)
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
const target = ref
.slice(2)
.split("/")
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
return target === undefined ? current : next(target, new Set([...seen, ref]))
}
return next(value, new Set())
}
const projectSchema = (document: Document, value: unknown): JsonSchema => {
if (!isRecord(value)) return {}
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
? fromSchemaOpenApi3_0(value)
: fromSchemaOpenApi3_1(value)
return Object.keys(normalized.definitions).length === 0
? normalized.schema
: { ...normalized.schema, $defs: normalized.definitions }
}
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
const components = isRecord(document.components) ? document.components : {}
const schemas = isRecord(components.schemas) ? components.schemas : {}
return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)]))
}
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
if (Object.keys(definitions).length === 0) return schema
const local = isRecord(schema.$defs) ? schema.$defs : {}
return { ...schema, $defs: { ...definitions, ...local } }
}
const isJsonMediaType = (mediaType: string): boolean => {
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
return normalized === "application/json" || normalized.endsWith("+json")
}
const isBinaryMediaType = (document: Document, mediaType: string, value: unknown): boolean => {
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
if (!isJsonMediaType(normalized) && !normalized.startsWith("text/")) return true
if (!isRecord(value)) return false
const schema = resolve(document, value.schema)
return isRecord(schema) && schema.format === "binary"
}
const jsonContent = (content: Record<string, unknown>): { readonly mediaType: string; readonly schema: unknown } | undefined => {
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
}
const isFlattenableObjectBody = (
schema: unknown,
requestRequired: boolean,
): schema is Record<string, unknown> & { readonly properties: Record<string, unknown> } =>
isRecord(schema) &&
requestRequired &&
schema.type === "object" &&
isRecord(schema.properties) &&
schema.additionalProperties === false &&
schema.nullable !== true &&
schema.allOf === undefined &&
schema.anyOf === undefined &&
schema.oneOf === undefined
type PlannedField = Omit<InputField, "inputName">
const operationParameters = (
document: Document,
pathItem: Record<string, unknown>,
operation: Record<string, unknown>,
): Parsed<ReadonlyArray<PlannedField>> => {
// Operation-level parameters override path-level ones sharing (location, name).
const declared = new Map<
string,
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
>()
for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) {
const resolved = resolve(document, raw)
if (!isRecord(resolved)) return { ok: false, reason: "parameter declaration is invalid or unresolved" }
const name = nonEmptyString(resolved.name)
const location = nonEmptyString(resolved.in)
if (name === undefined || location === undefined)
return { ok: false, reason: "parameter declaration is missing name or location" }
declared.set(`${location}:${name}`, { name, location, parameter: resolved })
}
const unordered: Array<PlannedField> = []
for (const item of declared.values()) {
const name = item.name
const location = item.location
const resolved = item.parameter
if (location === "cookie") return { ok: false, reason: `cookie parameter '${name}' is not supported` }
if (location !== "path" && location !== "query" && location !== "header") {
return { ok: false, reason: `parameter '${name}' uses unsupported location '${location}'` }
}
if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue
if (resolved.schema === undefined && resolved.content === undefined) {
return { ok: false, reason: `parameter '${name}' declares neither schema nor content` }
}
if (resolved.content !== undefined)
return { ok: false, reason: `parameter '${name}' uses unsupported content encoding` }
if (resolved.style !== undefined && nonEmptyString(resolved.style) === undefined) {
return { ok: false, reason: `parameter '${name}' has an invalid style` }
}
if (resolved.explode !== undefined && typeof resolved.explode !== "boolean") {
return { ok: false, reason: `parameter '${name}' has an invalid explode value` }
}
if (resolved.allowReserved !== undefined && typeof resolved.allowReserved !== "boolean") {
return { ok: false, reason: `parameter '${name}' has an invalid allowReserved value` }
}
if (resolved.allowReserved === true)
return { ok: false, reason: `parameter '${name}' uses unsupported allowReserved encoding` }
const declaredStyle = nonEmptyString(resolved.style) ?? (location === "query" ? "form" : "simple")
if (location === "query" && declaredStyle !== "form" && declaredStyle !== "deepObject") {
return { ok: false, reason: `query parameter '${name}' uses unsupported style '${declaredStyle}'` }
}
if (location !== "query" && declaredStyle !== "simple") {
return { ok: false, reason: `${location} parameter '${name}' uses unsupported style '${declaredStyle}'` }
}
const style = declaredStyle === "deepObject" ? "deepObject" : declaredStyle === "form" ? "form" : "simple"
const explode = typeof resolved.explode === "boolean" ? resolved.explode : style === "form"
if (style === "deepObject" && !explode) {
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
}
const base = projectSchema(document, resolved.schema)
const description = nonEmptyString(resolved.description)
unordered.push({
name,
location,
required: resolved.required === true || location === "path",
style,
explode,
schema: {
...base,
...(base.description === undefined && description !== undefined ? { description } : {}),
},
})
}
return {
ok: true,
value: parameterLocations.flatMap((location) => unordered.filter((field) => field.location === location)),
}
}
const operationBody = (
document: Document,
operation: Record<string, unknown>,
): Parsed<{ readonly fields: ReadonlyArray<PlannedField>; readonly body: Body | undefined }> => {
const resolved = resolve(document, operation.requestBody)
if (!isRecord(resolved)) return { ok: true, value: { fields: [], body: undefined } }
const content = isRecord(resolved.content) ? resolved.content : {}
const selected = jsonContent(content)
if (selected === undefined) {
return {
ok: false,
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
}
}
const schema = resolve(document, selected.schema)
const required = resolved.required === true
if (!isFlattenableObjectBody(schema, required)) {
return {
ok: true,
value: {
fields: [
{
name: "body",
location: "body",
required,
schema: projectSchema(document, selected.schema),
style: undefined,
explode: undefined,
},
],
body: { required, mode: "value", mediaType: selected.mediaType },
},
}
}
const requiredProperties = new Set(
Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === "string") : [],
)
return {
ok: true,
value: {
fields: Object.entries(schema.properties).map(([name, value]) => ({
name,
location: "body" as const,
required: required && requiredProperties.has(name),
schema: projectSchema(document, value),
style: undefined,
explode: undefined,
})),
body: { required, mode: "object", mediaType: selected.mediaType },
},
}
}
export const operationInput = (
document: Document,
pathItem: Record<string, unknown>,
operation: Record<string, unknown>,
): Parsed<OperationInput> => {
const parameters = operationParameters(document, pathItem, operation)
if (!parameters.ok) return parameters
const requestBody = operationBody(document, operation)
if (!requestBody.ok) return requestBody
const fields = [...parameters.value, ...requestBody.value.fields]
const conflicts = new Set(
[...Map.groupBy(fields, (field) => field.name)]
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
.map(([name]) => name),
)
const used = new Set<string>()
return {
ok: true,
value: {
fields: fields.map((field) => {
const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
const next = (index: number): string => {
const candidate = index === 1 ? base : `${base}_${index}`
return used.has(candidate) ? next(index + 1) : candidate
}
const inputName = next(1)
used.add(inputName)
return { ...field, inputName }
}),
body: requestBody.value.body,
},
}
}
export const inputSchema = (
fields: ReadonlyArray<InputField>,
definitions: Readonly<Record<string, JsonSchema>>,
): JsonSchema => {
const required = fields.filter((field) => field.required).map((field) => field.inputName)
return withDefinitions(
{
type: "object",
properties: Object.fromEntries(fields.map((field) => [field.inputName, field.schema])),
...(required.length === 0 ? {} : { required }),
},
definitions,
)
}
const successfulResponses = (
document: Document,
operation: Record<string, unknown>,
): Parsed<ReadonlyArray<Record<string, unknown>>> => {
if (!isRecord(operation.responses)) return { ok: true, value: [] }
const entries = Object.entries(operation.responses)
const selected = [
...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)),
...entries.filter(([status]) => status.toUpperCase() === "2XX"),
]
const responses: Array<Record<string, unknown>> = []
for (const [, value] of selected) {
const resolved = resolve(document, value)
if (!isRecord(resolved) || nonEmptyString(resolved.$ref) !== undefined) {
return { ok: false, reason: "successful response declaration is invalid or unresolved" }
}
responses.push(resolved)
}
return { ok: true, value: responses }
}
export const operationOutput = (
document: Document,
operation: Record<string, unknown>,
definitions: Readonly<Record<string, JsonSchema>>,
): Parsed<JsonSchema | undefined> => {
if (operation["x-websocket"] === true) return { ok: false, reason: "WebSocket operations are not supported" }
const responses = successfulResponses(document, operation)
if (!responses.ok) return responses
const streams = responses.value.some(
(response) =>
isRecord(response.content) &&
Object.keys(response.content).some(
(mediaType) => mediaType.split(";")[0]?.trim().toLowerCase() === "text/event-stream",
),
)
if (streams) return { ok: false, reason: "SSE operations are not supported" }
const binary = responses.value.some(
(response) =>
isRecord(response.content) &&
Object.entries(response.content).some(([mediaType, value]) => isBinaryMediaType(document, mediaType, value)),
)
if (binary) return { ok: false, reason: "binary responses are not supported" }
const outcomes: Array<JsonSchema> = []
for (const response of responses.value) {
if (response.content !== undefined && !isRecord(response.content)) return { ok: true, value: undefined }
const content = isRecord(response.content) ? response.content : {}
if (Object.keys(content).length === 0) {
outcomes.push({ type: "null" })
continue
}
for (const [mediaType, value] of Object.entries(content)) {
if (!isJsonMediaType(mediaType)) {
outcomes.push({ type: "string" })
continue
}
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
outcomes.push(projectSchema(document, value.schema))
}
}
if (outcomes.length === 0) return { ok: true, value: undefined }
return {
ok: true,
value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions),
}
}
const sanitizeOperationSegment = (raw: string): string => {
const base =
raw
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/^([0-9])/, "_$1") || "operation"
return isBlockedMember(base) ? `${base}_2` : base
}
const fallbackOperationId = (method: string, path: string): string =>
[
method,
...path
.split("/")
.filter((part) => part !== "")
.flatMap((part) => (part.startsWith("{") && part.endsWith("}") ? ["by", part.slice(1, -1)] : [part]))
.flatMap((part) => part.split(/[^A-Za-z0-9]+/).filter((word) => word !== "")),
]
.map((word, index) => {
const lower = word.toLowerCase()
return index === 0 ? lower : `${lower.charAt(0).toUpperCase()}${lower.slice(1)}`
})
.join("")
export const operationPath = (
method: string,
path: string,
operation: Record<string, unknown>,
used: ReadonlySet<string>,
namespaces: ReadonlySet<string>,
): ReadonlyArray<string> => {
const raw = nonEmptyString(operation.operationId)
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment)
if (isOperationPathAvailable(segments, used, namespaces)) return segments
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
if (conflict >= 0 && conflict + 1 < segments.length) {
const collapsed = segments.flatMap((segment, index) => {
if (index === conflict) {
const next = segments[index + 1] ?? ""
return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`]
}
return index === conflict + 1 ? [] : [segment]
})
if (isOperationPathAvailable(collapsed, used, namespaces)) return collapsed
}
const fallback = segments.join("_")
const next = (index: number): string => {
const candidate = `${fallback}_${index}`
return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1)
}
return [next(2)]
}
const isOperationPathAvailable = (
segments: ReadonlyArray<string>,
used: ReadonlySet<string>,
namespaces: ReadonlySet<string>,
): boolean => {
const key = segments.join(".")
if (used.has(key) || namespaces.has(key)) return false
return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join(".")))
}
export const specServerUrl = (source: Record<string, unknown>): Parsed<string> => {
const server = asArray(source.servers).find(isRecord)
const url = server === undefined ? undefined : nonEmptyString(server.url)
if (url === undefined) return { ok: false, reason: "spec declares no servers; pass baseUrl" }
if (/\{[^{}]+\}/.test(url)) {
return { ok: false, reason: `server URL '${url}' is not an absolute URL; pass baseUrl` }
}
return validateBaseUrl(url)
}
export const validateBaseUrl = (value: string): Parsed<string> => {
if (!/^https?:\/\//i.test(value)) return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
const url = URL.parse(value)
if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) {
return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
}
if (url.search !== "" || url.hash !== "") {
return { ok: false, reason: `server URL '${value}' contains an unsupported query string or fragment` }
}
return { ok: true, value }
}
export const securityRequirements = (value: unknown): Parsed<ReadonlyArray<SecurityRequirement>> => {
if (value === undefined) return { ok: true, value: [] }
if (!Array.isArray(value)) return { ok: false, reason: "security declaration is not an array" }
const requirements: Array<SecurityRequirement> = []
for (const item of value) {
if (!isRecord(item)) return { ok: false, reason: "security requirement is not an object" }
const requirement = Object.create(null) as Record<string, ReadonlyArray<string>>
for (const [name, scopes] of Object.entries(item)) {
if (!Array.isArray(scopes)) return { ok: false, reason: "security requirement scopes are not string arrays" }
const parsed = scopes.filter((scope): scope is string => typeof scope === "string")
if (parsed.length !== scopes.length) {
return { ok: false, reason: "security requirement scopes are not string arrays" }
}
requirement[name] = parsed
}
requirements.push(requirement)
}
return { ok: true, value: requirements }
}
export const operationSecurityRequirements = (
value: unknown,
defaults: Parsed<ReadonlyArray<SecurityRequirement>>,
schemes: Readonly<Record<string, SecurityScheme>>,
): Parsed<ReadonlyArray<SecurityRequirement>> => {
const parsed = value === undefined ? defaults : securityRequirements(value)
if (!parsed.ok) return parsed
const supported = parsed.value.filter((requirement) =>
Object.keys(requirement).every((name) => {
const scheme = own(schemes, name)
return scheme !== undefined && !(scheme.type === "apiKey" && scheme.in === "cookie")
}),
)
if (parsed.value.length === 0 || supported.length > 0) return { ok: true, value: supported }
const names = [...new Set(parsed.value.flatMap((requirement) => Object.keys(requirement)))]
const cookieScheme = names.find((name) => {
const definition = own(schemes, name)
return definition?.type === "apiKey" && definition.in === "cookie"
})
return {
ok: false,
reason:
cookieScheme === undefined
? `security requirement references missing or malformed scheme: ${names.join(", ")}`
: `cookie authentication '${cookieScheme}' is not supported`,
}
}
export const securitySchemes = (document: Document): Readonly<Record<string, SecurityScheme>> => {
const components = isRecord(document.components) ? document.components : {}
const declared = isRecord(components.securitySchemes) ? components.securitySchemes : {}
return Object.fromEntries(
Object.entries(declared).flatMap<readonly [string, SecurityScheme]>(([name, value]) => {
const resolved = resolve(document, value)
if (!isRecord(resolved)) return []
const type = nonEmptyString(resolved.type)
if (type === "apiKey") {
const carrier = nonEmptyString(resolved.in)
const parameter = nonEmptyString(resolved.name)
if (parameter === undefined || (carrier !== "header" && carrier !== "query" && carrier !== "cookie")) return []
return [[name, { type, name: parameter, in: carrier }] as const]
}
if (type === "http") {
const scheme = nonEmptyString(resolved.scheme)?.toLowerCase()
return scheme === undefined ? [] : [[name, { type, scheme }] as const]
}
if (type === "oauth2" || type === "openIdConnect") return [[name, { type }] as const]
return []
}),
)
}
+112
View File
@@ -0,0 +1,112 @@
import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import type { Definition, JsonSchema } from "../tool.js"
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
export type Document = Record<string, unknown>
/** The operation identity handed to auth resolution and errors. */
export type Operation = {
readonly operationId: string | undefined
readonly method: string
readonly path: string
readonly summary: string | undefined
readonly description: string | undefined
}
/** A resolved OpenAPI security scheme from `components.securitySchemes`. */
export type SecurityScheme =
| { readonly type: "apiKey"; readonly name: string; readonly in: "header" | "query" | "cookie" }
| { readonly type: "http"; readonly scheme: string }
| { readonly type: "oauth2" }
| { readonly type: "openIdConnect" }
/**
* Credential material returned by a host auth resolver. The carrier for `apiKey`
* comes from the scheme definition, not the credential. `header` is the escape
* hatch for nonstandard schemes.
*/
export type Credential =
| { readonly type: "bearer"; readonly token: string }
| { readonly type: "basic"; readonly username: string; readonly password: string }
| { readonly type: "apiKey"; readonly value: string }
| { readonly type: "header"; readonly name: string; readonly value: string }
/**
* Resolves credential material for one named security scheme at call time.
* `undefined` means unavailable, try the next OR alternative; a failure aborts
* the call rather than falling through.
*/
export type AuthResolver = (context: {
readonly name: string
readonly definition: SecurityScheme
readonly scopes: ReadonlyArray<string>
readonly operation: Operation
}) => Effect.Effect<Credential | undefined, unknown>
export type Options = {
readonly spec: Document
/** Overrides all document, path, and operation `servers`. Required when no applicable absolute server URL exists. */
readonly baseUrl?: string | undefined
/** Host credential resolution, keyed by security scheme name. */
readonly auth?: { readonly resolve: AuthResolver } | undefined
/** Static headers on every request. Not model-visible; declared header params may override them, auth always wins. */
readonly headers?: Readonly<Record<string, string>> | undefined
}
/** An operation that could not be represented as a tool, and why. */
export type Skipped = {
readonly method: string
readonly path: string
readonly reason: string
}
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
export type Result = {
/** Tool subtree; the host places it under a key in its `tools` tree. */
readonly tools: Tools
readonly skipped: ReadonlyArray<Skipped>
}
export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string }
export type InputLocation = "path" | "query" | "header" | "body"
export type InputField = {
/** Model-visible field name after cross-location collision handling. */
readonly inputName: string
/** Original parameter or body-property name used on the wire. */
readonly name: string
readonly location: InputLocation
readonly required: boolean
readonly schema: JsonSchema
readonly style: "simple" | "form" | "deepObject" | undefined
readonly explode: boolean | undefined
}
export type Body = { readonly required: boolean; readonly mode: "object" | "value"; readonly mediaType: string }
export type OperationInput = {
readonly fields: ReadonlyArray<InputField>
readonly body: Body | undefined
}
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
export type Plan = {
readonly operation: Operation
readonly url: string
readonly fields: ReadonlyArray<InputField>
readonly body: Body | undefined
readonly security: ReadonlyArray<SecurityRequirement>
readonly schemes: Readonly<Record<string, SecurityScheme>>
readonly auth: { readonly resolve: AuthResolver } | undefined
readonly headers: Readonly<Record<string, string>>
}
export type AppliedAuth = {
readonly headers: Readonly<Record<string, string>>
readonly query: Readonly<Record<string, string>>
}
-10
View File
@@ -1,10 +0,0 @@
/**
* Token estimation for budgeting model-facing text. Copied from
* `@opencode-ai/core/util/token` (chars / 4) so this package stays
* dependency-free; keep the two in sync if the heuristic ever changes.
*/
export * as Token from "./token.js"
const CHARS_PER_TOKEN = 4
export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))
+50 -48
View File
@@ -6,31 +6,35 @@ import {
identifierSegment,
inputProperties,
inputTypeScript,
isDefinition as isToolDefinition,
outputTypeScript,
type Definition,
} from "./tool.js"
import { estimate } from "./token.js"
} from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
export type HostTool<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type HostTools<R = never> = {
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
}
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
export type Services<Tools> = ServicesOf<Tools, []>
type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
? never
: Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R
: Tools extends object
? string extends keyof Tools
? never
: Services<Tools[keyof Tools]>
: never
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: Tools extends object
? string extends keyof Tools
? ServicesOf<Tools[string], [...Depth, unknown]>
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
: never
/** Minimal audit record retained for each admitted tool call. */
export type ToolCall = {
@@ -290,17 +294,16 @@ const definitions = <R>(
return entries
}
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
})
const visibleDefinitions = <R>(tools: HostTools<R>) =>
definitions(tools).flatMap(({ path, definition }) => {
const description = describeDefinition(path, definition)
return [{ path, definition, description }]
})
definitions(tools).map(({ path, definition }) => ({
path,
definition,
description: {
path,
description: definition.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
},
}))
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
visibleDefinitions(tools).map(({ description }) => description)
@@ -351,16 +354,10 @@ const termForms = (term: string): Array<string> => {
return forms
}
const firstLine = (text: string) => text.split("\n", 1)[0]!.trim()
/** One-line description used on inline catalog lines; the full text stays in search results. */
const brief = (text: string, max = 120) => {
const line = firstLine(text)
return line.length > max ? line.slice(0, max - 1) + "..." : line
}
const catalogLine = (tool: ToolDescription) => {
const description = brief(tool.description)
// Inline catalog lines use only a compact first line; full text stays in search results.
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}`
}
@@ -430,7 +427,7 @@ export const discoveryPlan = <R>(
picked: new Set<ToolDescription>(),
queue: [...group].sort(
(left, right) =>
estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path),
),
}))
let used = 0
@@ -439,7 +436,7 @@ export const discoveryPlan = <R>(
const stillActive: typeof active = []
for (const selection of active) {
const tool = selection.queue[0]!
const cost = estimate(catalogLine(tool))
const cost = estimateTokens(catalogLine(tool))
if (used + cost > maxInlineCatalogTokens) continue
selection.queue.shift()
selection.picked.add(tool)
@@ -458,8 +455,8 @@ export const discoveryPlan = <R>(
// Section order is deliberate: workflow first (the top is the least likely part of a long
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
// catalog at the bottom. Example call forms use explicit `<namespace>.<tool>` placeholders -
// never a real or fabricated tool name.
// 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
@@ -467,6 +464,7 @@ export const discoveryPlan = <R>(
: 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."]),
]
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
@@ -480,14 +478,14 @@ 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: `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
'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.",
]
: [
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
'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 it with the result's `path` as-is (never guess segments): `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
"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.",
]),
@@ -504,7 +502,7 @@ export const discoveryPlan = <R>(
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime 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.",
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
'- 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
? []
@@ -635,9 +633,6 @@ export type ToolRuntime<R = never> = {
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
const failureMessage = (error: unknown): string =>
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
export const make = <R>(
tools: HostTools<R>,
/** Undefined means unlimited tool calls. */
@@ -656,9 +651,16 @@ export const make = <R>(
const startedAt = Date.now()
return effect.pipe(
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
Effect.tapError((error) =>
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) }),
),
Effect.tapError((error) => {
const message =
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
return onEnd({
...call,
durationMs: Date.now() - startedAt,
outcome: "failure",
message,
})
}),
)
}
+301
View File
@@ -0,0 +1,301 @@
import { JsonPointer, Schema } from "effect"
import type { Definition, JsonSchema, SchemaType } from "./tool.js"
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
/**
* Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime,
* with dot access as a tool-path segment). Anything else must be quoted/bracketed.
*/
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
const effectNumberSentinel = (schema: JsonSchema) =>
schema.type === "string" &&
Array.isArray(schema.enum) &&
schema.enum.length === 1 &&
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
const intersection = (members: ReadonlyArray<string>): string => {
const concrete = members.filter((member) => member !== "unknown")
if (concrete.length === 0) return "unknown"
if (concrete.length === 1) return concrete[0] ?? "unknown"
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
}
/**
* Recursion ceiling for schema rendering. Object, array, and union recursion all increment
* depth, so this bounds every recursion path - pathological or structurally cyclic schemas
* degrade to `unknown` instead of overflowing the stack (rendering must never throw).
*/
const MAX_RENDER_DEPTH = 8
type RenderContext = {
readonly definitions: Readonly<Record<string, JsonSchema>>
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
readonly pretty: boolean
}
const hasUnresolvedRef = (
schema: JsonSchema,
definitions: Readonly<Record<string, JsonSchema>>,
seen: ReadonlySet<string> = new Set(),
visited: ReadonlySet<JsonSchema> = new Set(),
): boolean => {
if (visited.has(schema)) return false
const nextVisited = new Set([...visited, schema])
if (schema.$ref !== undefined) {
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
if (name === undefined || definitions[name] === undefined || seen.has(name)) return true
if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true
}
return [
...(schema.anyOf ?? []),
...(schema.oneOf ?? []),
...(schema.allOf ?? []),
...Object.values(schema.properties ?? {}),
...(schema.items === undefined ? [] : [schema.items]),
...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []),
].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
}
/**
* Schema constraints a TypeScript type cannot express natively but a model benefits from,
* surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
*/
const docTags = (schema: JsonSchema): Array<string> => {
const tags: Array<string> = []
if (schema.deprecated === true) tags.push("@deprecated")
if (schema.default !== undefined) {
try {
const rendered = JSON.stringify(schema.default)
if (rendered !== undefined) tags.push(`@default ${rendered}`)
} catch {
// unserializable default: skip rather than emit a broken tag
}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
return tags
}
/**
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
* preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
* callers can prepend it directly to the field line.
*/
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
const renderSchema = (
schema: JsonSchema,
ctx: RenderContext,
depth = 0,
seen: ReadonlySet<string> = new Set(),
): string => {
if (depth > MAX_RENDER_DEPTH) return "unknown"
const nested =
schema.definitions === undefined && schema.$defs === undefined
? ctx
: { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } }
if (schema.$ref) {
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
if (!name || !nested.definitions[name] || seen.has(name)) return "unknown"
return intersection([
renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])),
renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen),
])
}
if (schema.const !== undefined) return renderLiteral(schema.const)
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
const alternatives = schema.anyOf ?? schema.oneOf
if (alternatives) {
// Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
// { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
// real JSON Schema unions such as `string | number` or `number | null` must keep
// every branch.
if (
alternatives.some((item) => item.type === "number") &&
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
)
return "number"
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
if (
alternatives.length === 2 &&
alternatives[0]?.type === "object" &&
alternatives[0].properties === undefined &&
alternatives[1]?.type === "array" &&
alternatives[1].items === undefined
) {
return "{}"
}
const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen))
if (members.some((member) => member === "unknown")) return "unknown"
return intersection([
members.join(" | "),
renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen),
])
}
if (schema.allOf) {
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
}
if (Array.isArray(schema.type)) {
return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ")
}
if (schema.type === "string") return "string"
if (schema.type === "number" || schema.type === "integer") return "number"
if (schema.type === "boolean") return "boolean"
if (schema.type === "null") return "null"
if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>`
if (schema.type === "object" || schema.properties) {
const required = new Set(schema.required ?? [])
const properties = Object.entries(schema.properties ?? {})
const additional = schema.additionalProperties
const indexType =
additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined
const field = ([name, value]: readonly [string, JsonSchema]) =>
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}`
if (!ctx.pretty) {
const fields = properties.map(field)
if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
}
// Pretty: an indented block, each described field preceded by its JSDoc comment.
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)}`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
return "unknown"
}
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
try {
const visible = decoded ? Schema.toType(schema) : schema
const document = Schema.toJsonSchemaDocument(visible) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
} catch {
return "unknown"
}
}
/** Renders a raw JSON Schema document as a TypeScript type string. */
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
try {
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
} catch {
return "unknown"
}
}
/** One input property of a tool, extracted best-effort from its input schema. */
export type InputProperty = {
readonly name: string
readonly description: string | undefined
readonly required: boolean
}
/**
* The property names, descriptions, and required flags of a tool's input schema - the raw
* material for search text. Best-effort: Effect Schemas go through their
* JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
* directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
* Anything unresolvable yields `[]` (search falls back to path + description).
*/
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
try {
const document = isEffectSchema(definition.input)
? (Schema.toJsonSchemaDocument(definition.input) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
})
: {
schema: definition.input,
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
}
const definitions = document.definitions ?? {}
let schema = document.schema
if (schema.$ref !== undefined) {
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
const resolved = name === undefined ? undefined : definitions[name]
if (resolved === undefined) return []
schema = resolved
}
const required = new Set(schema.required ?? [])
return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
name,
description: typeof value.description === "string" ? value.description : undefined,
required: required.has(name),
}))
} catch {
return []
}
}
/**
* The model-visible TypeScript type of a tool's input. `pretty` renders an indented
* multiline block with schema descriptions and constraints as JSDoc comments on the
* fields; the default stays the compact single-line form.
*/
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
isEffectSchema(definition.input)
? toTypeScript(definition.input, false, pretty)
: jsonSchemaToTypeScript(definition.input, pretty)
/**
* The model-visible TypeScript type of a tool's result; tools without an output schema
* return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
*/
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
definition.output === undefined
? "unknown"
: isEffectSchema(definition.output)
? toTypeScript(definition.output, true, pretty)
: jsonSchemaToTypeScript(definition.output, pretty)
/**
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
* JSON-Schema-described inputs pass through unvalidated (render-only).
*/
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
/**
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
* transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
* the host value through unchanged.
*/
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
definition.output !== undefined && isEffectSchema(definition.output)
? Schema.decodeUnknownSync(definition.output)(value)
: value
+8 -260
View File
@@ -13,6 +13,7 @@ export type JsonSchema = {
readonly const?: unknown
readonly anyOf?: ReadonlyArray<JsonSchema>
readonly oneOf?: ReadonlyArray<JsonSchema>
readonly allOf?: ReadonlyArray<JsonSchema>
readonly properties?: Readonly<Record<string, JsonSchema>>
readonly required?: ReadonlyArray<string>
readonly items?: JsonSchema
@@ -29,25 +30,25 @@ export type JsonSchema = {
}
/** Either a validating Effect Schema or a render-only JSON Schema document. */
export type ToolSchema = Schema.Decoder<unknown> | JsonSchema
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
/** Schema-backed tool definition consumed by a CodeMode tool tree. */
export type Definition<R = never> = {
readonly _tag: "CodeModeTool"
readonly description: string
readonly input: ToolSchema
readonly output: ToolSchema | undefined
readonly input: SchemaType
readonly output: SchemaType | undefined
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
}
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
export type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
export type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
/** Options for defining one CodeMode tool. */
export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R = never> = {
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
readonly description: string
readonly input: I
readonly output?: O
@@ -57,256 +58,6 @@ export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R =
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
/**
* Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime,
* with dot access as a tool-path segment). Anything else must be quoted/bracketed.
*/
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
const effectNumberSentinel = (schema: JsonSchema) =>
schema.type === "string" &&
Array.isArray(schema.enum) &&
schema.enum.length === 1 &&
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
/**
* Recursion ceiling for schema rendering. Object, array, and union recursion all increment
* depth, so this bounds every recursion path - pathological or structurally cyclic schemas
* degrade to `unknown` instead of overflowing the stack (rendering must never throw).
*/
const MAX_RENDER_DEPTH = 8
type RenderContext = {
readonly definitions: Readonly<Record<string, JsonSchema>>
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
readonly pretty: boolean
}
/**
* Schema constraints a TypeScript type cannot express natively but a model benefits from,
* surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
*/
const docTags = (schema: JsonSchema): Array<string> => {
const tags: Array<string> = []
if (schema.deprecated === true) tags.push("@deprecated")
if (schema.default !== undefined) {
try {
const rendered = JSON.stringify(schema.default)
if (rendered !== undefined) tags.push(`@default ${rendered}`)
} catch {
// unserializable default: skip rather than emit a broken tag
}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
return tags
}
/**
* Format a schema `description` plus `tags` as a JSDoc comment at the given indent,
* preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a
* `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and
* blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so
* callers can prepend it directly to the field line.
*/
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
const renderSchema = (
schema: JsonSchema,
ctx: RenderContext,
depth = 0,
seen: ReadonlySet<string> = new Set(),
): string => {
if (depth > MAX_RENDER_DEPTH) return "unknown"
if (schema.$ref) {
const name = schema.$ref.split("/").pop()
if (!name || !ctx.definitions[name]) return name ?? "unknown"
if (seen.has(name)) return name // recursive type: reference by name rather than loop
return renderSchema(ctx.definitions[name], ctx, depth, new Set([...seen, name]))
}
if (schema.const !== undefined) return renderLiteral(schema.const)
if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
const alternatives = schema.anyOf ?? schema.oneOf
if (alternatives) {
// Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
// { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
// real JSON Schema unions such as `string | number` or `number | null` must keep
// every branch.
if (
alternatives.some((item) => item.type === "number") &&
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
)
return "number"
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
if (
alternatives.length === 2 &&
alternatives[0]?.type === "object" &&
alternatives[0].properties === undefined &&
alternatives[1]?.type === "array" &&
alternatives[1].items === undefined
) {
return "{}"
}
return alternatives.map((item) => renderSchema(item, ctx, depth + 1, seen)).join(" | ")
}
if (Array.isArray(schema.type)) {
return schema.type.map((item) => renderSchema({ type: item }, ctx, depth + 1, seen)).join(" | ")
}
if (schema.type === "string") return "string"
if (schema.type === "number" || schema.type === "integer") return "number"
if (schema.type === "boolean") return "boolean"
if (schema.type === "null") return "null"
if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, ctx, depth + 1, seen)}>`
if (schema.type === "object" || schema.properties) {
const required = new Set(schema.required ?? [])
const properties = Object.entries(schema.properties ?? {})
const additional = schema.additionalProperties
const indexType =
additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
const field = ([name, value]: readonly [string, JsonSchema]) =>
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}`
if (!ctx.pretty) {
const fields = properties.map(field)
if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
}
// Pretty: an indented block, each described field preceded by its JSDoc comment.
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)}`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
return "unknown"
}
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
try {
const visible = decoded ? Schema.toType(schema) : schema
const document = Schema.toJsonSchemaDocument(visible) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
} catch {
return "unknown"
}
}
/** Renders a raw JSON Schema document as a TypeScript type string. */
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
try {
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
} catch {
return "unknown"
}
}
/** One input property of a tool, extracted best-effort from its input schema. */
export type InputProperty = {
readonly name: string
readonly description: string | undefined
readonly required: boolean
}
/**
* The property names, descriptions, and required flags of a tool's input schema - the raw
* material for search text. Best-effort: Effect Schemas go through their
* JSON Schema document (the same emission signature rendering uses); JSON Schemas are read
* directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present.
* Anything unresolvable yields `[]` (search falls back to path + description).
*/
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
try {
const document = isEffectSchema(definition.input)
? (Schema.toJsonSchemaDocument(definition.input) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
})
: {
schema: definition.input,
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
}
const definitions = document.definitions ?? {}
let schema = document.schema
if (schema.$ref !== undefined) {
const name = schema.$ref.split("/").pop()
const resolved = name === undefined ? undefined : definitions[name]
if (resolved === undefined) return []
schema = resolved
}
const required = new Set(schema.required ?? [])
return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
name,
description: typeof value.description === "string" ? value.description : undefined,
required: required.has(name),
}))
} catch {
return []
}
}
/**
* The model-visible TypeScript type of a tool's input. `pretty` renders an indented
* multiline block with schema descriptions and constraints as JSDoc comments on the
* fields; the default stays the compact single-line form.
*/
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
isEffectSchema(definition.input)
? toTypeScript(definition.input, false, pretty)
: jsonSchemaToTypeScript(definition.input, pretty)
/**
* The model-visible TypeScript type of a tool's result; tools without an output schema
* return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
*/
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
definition.output === undefined
? "unknown"
: isEffectSchema(definition.output)
? toTypeScript(definition.output, true, pretty)
: jsonSchemaToTypeScript(definition.output, pretty)
/**
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
* JSON-Schema-described inputs pass through unvalidated (render-only).
*/
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
/**
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
* transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
* the host value through unchanged.
*/
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
definition.output !== undefined && isEffectSchema(definition.output)
? Schema.decodeUnknownSync(definition.output)(value)
: value
/**
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
*
@@ -334,7 +85,7 @@ export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unkn
* })
* ```
*/
export const make = <I extends ToolSchema, const O extends ToolSchema | undefined = undefined, R = never>(
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
options: Options<I, O, R>,
): Definition<R> => ({
_tag: "CodeModeTool",
@@ -343,6 +94,3 @@ export const make = <I extends ToolSchema, const O extends ToolSchema | undefine
output: options.output,
run: (input) => options.run(input as InputType<I>),
})
/** Constructors for schema-backed tools exposed inside CodeMode programs. */
export const Tool = { make, isDefinition }
+17 -32
View File
@@ -1,16 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import {
CodeMode,
ExecuteInputSchema,
ExecuteResultSchema,
Tool,
toolError,
type ExecutionLimits,
} from "../src/index.js"
import type { Definition } from "../src/tool.js"
import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Definition<never>) =>
const run = (tool: Tool.Definition<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
@@ -235,7 +227,7 @@ describe("CodeMode console capture", () => {
logs: ['Thread info: {"name":"Demo","count":2}', "[warn] careful"],
toolCalls: [],
})
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})
test("keeps logs captured before failures", async () => {
@@ -356,7 +348,7 @@ describe("CodeMode output budget", () => {
})
test("truncates an oversized result value with a marker instead of failing", async () => {
const limits: ExecutionLimits = { maxOutputBytes: 40 }
const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 }
const result = await Effect.runPromise(
CodeMode.execute({
code: `return { data: "${"x".repeat(200)}" }`,
@@ -371,11 +363,11 @@ describe("CodeMode output budget", () => {
expect(result.value).toMatch(
/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/,
)
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})
test("keeps leading logs within the remaining budget and marks the cut", async () => {
const limits: ExecutionLimits = { maxOutputBytes: 40 }
const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 }
const result = await Effect.runPromise(
CodeMode.execute({
code: `
@@ -501,24 +493,17 @@ describe("CodeMode public contract", () => {
const tools = { orders: { lookup } }
const source = `return await tools.orders.lookup({ id: "order_42" })`
test("keeps one-shot, reusable, and agent-tool execution equivalent", async () => {
test("keeps one-shot and reusable execution equivalent", async () => {
const runtime = CodeMode.make({ tools })
const agentTool = runtime.agentTool()
const [oneShot, reusable, projected] = await Promise.all([
const [oneShot, reusable] = await Promise.all([
Effect.runPromise(CodeMode.execute({ tools, code: source })),
Effect.runPromise(runtime.execute(source)),
Effect.runPromise(agentTool.execute({ code: source })),
])
expect(reusable).toStrictEqual(oneShot)
expect(projected).toStrictEqual(oneShot)
expect(agentTool.name).toBe("code")
expect(agentTool.input).toBe(ExecuteInputSchema)
expect(agentTool.output).toBe(ExecuteResultSchema)
expect(agentTool.description).toBe(runtime.instructions())
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(
projected,
)
const input: CodeMode.Input = { code: source }
expect(Schema.decodeUnknownSync(CodeMode.Input)(input)).toStrictEqual(input)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable)
})
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
@@ -622,12 +607,12 @@ describe("CodeMode public contract", () => {
)
expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("raw payloads get truncated and waste context")
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
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("bracket notation may appear for names that are not JavaScript identifiers")
// Placeholders use the <namespace>.<tool>/<field> style ONLY - no fabricated tool
// names, and no real catalog tools cherry-picked into example lines.
// 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).not.toContain("total_count")
expect(instructions).not.toContain("list_issues")
@@ -640,7 +625,7 @@ describe("CodeMode public contract", () => {
// 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. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
'1. If the exact signature is not listed below, first search: `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
)
expect(partial).toContain(
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`",
@@ -1035,7 +1020,7 @@ describe("CodeMode public contract", () => {
value: { top: null, nested: [1, null] },
toolCalls: [],
})
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})
test("rejects invalid configuration and discovery limits", async () => {
+245
View File
@@ -0,0 +1,245 @@
{
"openapi": "3.1.0",
"info": {
"title": "CodeMode Happy Path",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.test/v1"
}
],
"security": [
{
"BearerAuth": []
}
],
"paths": {
"/users/{userId}": {
"parameters": [
{
"$ref": "#/components/parameters/UserId"
}
],
"get": {
"operationId": "users.get",
"summary": "Get a user",
"parameters": [
{
"name": "include",
"in": "query",
"style": "form",
"explode": false,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "verbose",
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "X-Trace-ID",
"in": "header",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/UserResponse"
}
}
},
"delete": {
"operationId": "users.remove",
"summary": "Remove a user",
"responses": {
"204": {
"description": "Removed"
}
}
}
},
"/users": {
"post": {
"operationId": "users.create",
"summary": "Create a user",
"security": [
{
"ApiKey": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"role": {
"type": "string",
"enum": [
"admin",
"member"
]
}
},
"required": [
"name",
"email"
],
"additionalProperties": false
}
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/vnd.example+json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
},
"/search": {
"get": {
"operationId": "search.run",
"summary": "Search users",
"security": [],
"parameters": [
{
"name": "filter",
"in": "query",
"style": "deepObject",
"explode": true,
"schema": {
"type": "object",
"properties": {
"query": {
"type": "string"
},
"page": {
"type": "integer"
}
},
"required": [
"query"
],
"additionalProperties": false
}
},
{
"name": "tags",
"in": "query",
"style": "form",
"explode": true,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
],
"responses": {
"200": {
"description": "Summary",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
}
},
"components": {
"parameters": {
"UserId": {
"name": "userId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
},
"responses": {
"UserResponse": {
"description": "A user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
},
"schemas": {
"User": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"role": {
"type": "string",
"enum": [
"admin",
"member"
]
}
},
"required": [
"id",
"name",
"email"
],
"additionalProperties": false
}
},
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer"
},
"ApiKey": {
"type": "apiKey",
"in": "query",
"name": "api_key"
}
}
}
}
File diff suppressed because it is too large Load Diff

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